Skip to content

Add tests - #830

Merged
giuscris merged 51 commits into
2.xfrom
tests
Oct 13, 2026
Merged

giuscris merged 51 commits into
2.xfrom
tests

Conversation

@giuscris

@giuscris giuscris commented Dec 30, 2025 •

Copy link
Copy Markdown
Member

This pull request introduces a comprehensive PHPUnit test suite for Formwork, along with a small set of targeted changes to the core that make the application testable without altering its public behavior or architecture. The changes include adding application path accessors to App, allowing the application to be unloaded and reloaded, introducing an HTTP transport abstraction for the Client, and wiring PHPUnit into the CI workflow.

Testing infrastructure

  • Added PHPUnit 12 as a dev dependency, with composer test and composer test:coverage scripts, a phpunit.xml.dist configuration, and a Formwork\Tests PSR-4 autoload-dev namespace. (composer.json, phpunit.xml.dist)
  • Added a test bootstrap that creates the application over a temporary installation, so tests never read or write the real site, cache or configuration directories, and never load locally installed plugins. (tests/bootstrap.php, tests/TestInstallation.php)
  • Added the #[UsesApplication] attribute and a TestCase base class that loads the application only for the test classes that need it, plus a helper to capture user deprecations. (tests/UsesApplication.php, tests/TestApplication.php, tests/TestCase.php)
  • Added 203 test classes (4398 tests) covering every namespace in formwork/src, including Cms, Http, Services, Fields, Pages, Panel controllers, Backup, Updater, Cache, Log and Utils. The suite passes in any execution order. (tests/Unit/**)

Application changes

  • App::__construct() now accepts an optional $rootPath, falling back to the ROOT_PATH constant and throwing a RuntimeException if neither is available. (formwork/src/Cms/App.php)
  • Added App::rootPath() and App::systemPath() to expose the installation paths. systemPath() uses SYSTEM_PATH when defined, otherwise the directory containing Formwork. This is a first step towards removing the path constants. (formwork/src/Cms/App.php)
  • Added App::unload(), which resets the services container so that the next load() starts from a clean state. (formwork/src/Cms/App.php)
  • Moved the installation of the error handler from App::load() to App::run(), so that loading the application no longer modifies global error handling. (formwork/src/Cms/App.php)

Services and paths

  • Backupper, Updater and RateLimiter now receive the root path as a string $rootPath constructor parameter, provided by the container from App::rootPath(), instead of reading ROOT_PATH directly. (formwork/src/Backup/Backupper.php, formwork/src/Updater/Updater.php, formwork/src/Authentication/RateLimiter.php, formwork/config/services/services.php)
  • ConfigServiceLoader resolves the %ROOT_PATH% and %SYSTEM_PATH% configuration macros from the application, and ConfigServiceLoader::clearCache() now takes the root path as an argument. (formwork/src/Services/Loaders/ConfigServiceLoader.php)
  • Updated CacheController, OptionsController, PluginsController, ToolsController and CacheCommand to use App::rootPath() and App::systemPath() instead of the path constants.

HTTP client

  • Introduced HttpTransportInterface and its default implementation StreamHttpTransport. Client now delegates the actual request to the transport, so responses, redirects and connection errors can be tested without network access. (formwork/src/Http/Client.php, formwork/src/Http/Transport/HttpTransportInterface.php, formwork/src/Http/Transport/StreamHttpTransport.php)
  • Updater now accepts an optional Client in its constructor, defaulting to a client configured for the GitHub API. (formwork/src/Updater/Updater.php)

Dynamic field values

  • Dynamic field variables are now cached by the loader closure created in SchemesServiceLoader rather than in a static property of DynamicFieldValue, so each application load gets its own set of variables. (formwork/src/Fields/Dynamic/DynamicFieldValue.php, formwork/src/Services/Loaders/SchemesServiceLoader.php)

CI and packaging

  • Added a PHPUnit step to the Check PHP workflow. (.github/workflows/check-php.yaml)
  • Excluded tests/ and phpunit.xml.dist from release archives, git archive exports and backups. (.github/workflows/release.yaml, .gitattributes, formwork/config/system.yaml)
  • Excluded tests/ from PHPStan and Rector analysis. (formwork/phpstan.neon, formwork/.rector.php)

giuscris and others added 14 commits October 11, 2026 23:12
- Use a consistent `Fixtures` directory name (Http, Pages) and fix the
  `fixtures` references in FileSystemTest and MimeTypeTest, which only
  worked on case-insensitive filesystems
- Make FormTest independent from the execution order by loading the
  schemes service (which registers `FieldFactory`) in `setUp()`
- Cover previously untested behavior: `Request::isLocalhost()` with IPv6,
  `Request::validateReferer()` negative cases, forwarded protocols,
  single-byte and clamped ranges in `FileResponse`, and the destruction
  of the old session on `Session::regenerate()`
- Replace weak or misleading tests (page icon, slug validation, publish
  date windows, content file resolution, lifecycle events, non-GET
  request content, `Request::fromGlobals()`) with assertions on actual
  behavior

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
@giuscris
giuscris force-pushed the tests branch 2 times, most recently from 29f2ebc to e2fff8e Compare October 11, 2026 22:22
@giuscris giuscris added this to the 2.4.0 milestone Oct 11, 2026
claude and others added 26 commits October 13, 2026 00:55
Public tests are grouped by topic (new tests next to the ones they
extend, data providers right after their first user) and helpers follow
the php-cs-fixer ordered_class_elements order.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
…te UsersController tests

Covers GHSA-34p4, GHSA-xh84, GHSA-vf6x, GHSA-5pxr, GHSA-7j46 (server-side rendering).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
RequestMethod semantics, Container::resolveEagerServices(), ServiceDefinition::isLazy(), Header::parseETags(), conditional request edge cases, PageCollection::search() scores, Date with immutable date times.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Covers Methods, SingletonClass, StaticClass, TranslatedException, Event, EventDispatcher, ListenerProvider and the Cms/Plugins events. Failing tests report: singleton subclasses share their parent instance, TranslatedException cannot wrap errors.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Covers Tokenizer, Token, TokenStream, Parser, the nodes, NodeInterpolator and Interpolator. Failing tests report: numbers merged with following commas or spaces, quotes stripped from string contents, null values traversing into top level variables, private members raising errors instead of InterpolationException, calls on closures evaluated after traversal, magic __call shadowing public properties, ImplicitArrayKeyNode::value() uninitialized.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Shared PSR-16 behavior tests for ArrayCache and FilesCache, plus CacheItem and CacheManager. Failing tests report: namespaces accept a trailing newline, zero or negative TTLs do not delete the item, truncated cache files raise ParseError instead of UnexpectedValueException.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Covers Logger, handlers, formatters, Registry and the deprecated Log. Failing tests report: log forging through line breaks in the text formatter, JsonException on invalid UTF-8 or non-finite numbers while logging, TypeError for array levels, null values in Registry, files created when merely reading a Registry.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Failing tests report: language prefixes matched case-insensitively but not resolved, regional Accept-Language codes never matching the base language, unquoted language codes in the prefix regex, loading a missing translation file throwing, translations keeping a stale fallback after new strings are loaded.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Cover CsrfToken, Authenticator (with in-memory session and real rate
limiter), Users (User, UserCollection, UserFactory, Role, Permissions,
ColorScheme, Password, InitialsImageGenerator).

Failing tests intentionally document bugs in the sources:
- usernames are not validated, so `../x` writes the account file outside
  the accounts directory
- getters return stale values after email changes through the setter
- setImage accepts sibling directories sharing the accounts prefix
- emails differing only in case are accepted as distinct
- initials are computed from HTML-escaped names ("Tom & Jerry" -> "TA")
- Password: bcrypt silently truncates at 72 bytes, null byte raises
  ValueError
- login of a user without a password hash throws
  UnexpectedValueException instead of AuthenticationFailedException
- isLoggedIn() is true for the session of a deleted user

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Cover Model (accessor attributes, deprecated implicit access, set/get,
toArray), Schemes (Scheme, Schemes, SchemeFactory, SchemeOptions),
FieldFactory, DynamicFieldValue and the Layout classes (Layout, Section,
Tab and their collections) plus the shared Fields Translations trait.

Failing tests intentionally document bugs in the sources:
- FieldFactory loops forever on circular field type extension
  (loop-a extends loop-b extends loop-a)
- DynamicFieldValue stays flagged as "computing" after a failed
  computation, so a retry reports a bogus RecursionException
- Scheme::title() throws a TypeError for per-language titles that miss
  the current language or contain unknown translation keys
- Section/Tab labels (Fields Translations trait) throw TypeError for
  per-language labels missing the current language and
  InvalidArgumentException for unknown {{keys}}
- Model::toArray() crashes on uninitialized typed Getter properties
- Model::toArray() exports the whole App instance
- Model::set() does not apply field validation when fields were not
  loaded yet, so stored data depends on access order

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Cover Asset, AssetCollection and Assets (including namespaced keys and
path traversal payloads against the resolution roots), Plugin,
PluginManifest, PluginFactory, Plugins, Template, TemplateFactory and the
view Renderer. Plugins are exercised through generated plugin
directories in the temporary path.

Failing tests intentionally document bugs in the sources:
- Asset throws the generic FileNotFoundException for missing files, the
  documented AssetNotFoundException is never raised
- Plugin manifest defaults are stored under plugins.<id> while
  isEnabled(), the panel and Plugins use plugins.<camelCaseName>, so the
  defaults of hyphenated plugins are never found
- Plugin id validation accepts a trailing newline ("demo\n") because of
  the `$` anchor
- Plugin::getEventListeners() registers every public method starting
  with "on" (onlineUsers() -> event "lineUsers")
- Plugins::initialize() registers the listeners again when called twice
- PluginManifest raises TypeError instead of UnexpectedValueException for
  values of the wrong type
- Renderer::load() lets a variable named `_filename` replace the script
  being included

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
…sData

Cover AbstractParser/AbstractEncoder, the CommonMark extension
(FormworkExtension, LinkBaseProcessor, ImageAltProcessor, ImageRenderer)
through Markdown::parse with hostile destinations and attribute values,
PhpDomParser (malformed XML, XXE and entity expansion payloads) and
Html5Parser, policy invariants for HtmlReference and SvgReference, and
FilesData.

Failing tests intentionally document bugs in the sources:
- Markdown links or images with an empty destination (`[x]()`) raise
  "Uninitialized string offset 0" in LinkBaseProcessor
- Markdown::parse() without a `site` option crashes on relative links
  and on any image (null dereference in LinkBaseProcessor and
  ImageAltProcessor) although the option is optional in the schema

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Cover PageTraversal, PageStatus, PageUid and PageUri on generated site
trees (nesting, siblings boundaries, caching, date handling, URI
building), plus PaginationFactory.

Failing tests intentionally document bugs in the sources:
- PageStatus throws "Invalid publish date" for unquoted YAML dates and
  numeric timestamps (YAML parses `publishDate: 2024-01-01` as an int)
- PageStatus lets the InvalidArgumentException of an unparsable date
  escape, so a typo in one frontmatter breaks every listing using the
  page status
- PageUri::uri() normalizes paths against the filesystem root instead of
  the application root, so `../` segments can climb above the request
  root

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Cover Backupper (archive naming, name validation, pruning, listing),
ZipErrors, SemVer (parsing, normalization, comparison, ~ and ^ ranges)
and Updater with a stubbed HTTP client. The updater tests never let an
update reach the extraction into ROOT_PATH: archives containing
escaping entries are only fed to the extractor to check they are
refused.

Failing tests intentionally document bugs in the sources:
- Backupper builds the archive name from the hostname without
  sanitizing it, so IPv6 hosts ("[2001:db8::1]") make web backups fail
- Backupper::getBackups() and deleteOldBackups() key the backups by
  modification time, so backups created in the same second hide each
  other and are miscounted when pruning
- Backupper::getBackups() creates the backup directory non-recursively
  while backup() creates it recursively
- SemVer::fromString() accepts a trailing newline ("1.2.3\n")
- SemVer treats the "pl"/"patch" prerelease as newer than its release
- Updater::latestRelease() throws before the first check although it is
  declared nullable
- Updater: GitHub API error payloads (rate limit) end in an ErrorException
  for the undefined `published_at` key instead of a clear RuntimeException
- Updater: the ETag comparison is unreachable for the installed version
  (isVersionInstallable() is false for it), so a re-released archive of
  the same version is never offered

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Cover Debug (dump of scalars, arrays, objects, enums, closures,
resources, dump gating on the Accept header) and CodeDumper (context
windows, highlighting, escaping, backtrace frame parameters, sensitive
parameters).

Failing tests intentionally document bugs in the sources:
- Debug keeps every dumped object id forever, so dumping the same
  object twice (or twice inside one structure) wrongly reports a
  circular reference
- Debug does not escape property names, so dumping decoded user input
  (json_decode objects) injects markup
- Debug throws for closed resources and overflows the stack for
  self-referencing arrays
- CodeDumper does not escape class and function names of backtrace
  frames
- CodeDumper cannot dump frames whose file is not readable (evaluated
  code), which breaks the error page
- CodeDumper line numbers are misaligned when the last line is a power
  of ten

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Cover AssetsController, FilesController, ErrorsController and
PageController (rendering, unpublished pages, page files, canonical
redirects, pagination/taxonomy gating, maintenance mode, statistics
tracking and the page cache) on top of a lightweight controller
container, plus the plugin AssetsController.

Failing tests intentionally document missing path confinement in the
controllers. The router normalizes the request path before matching, so
these are not reachable from HTTP today, but each controller trusts its
route parameters and joins them to a base directory with a path join that
resolves `..`:
- AssetsController::asset(): `id` and `name`
- FilesController::file(): `name`
- Plugins AssetsController::asset(): `type` (the `file` parameter is
  already confined with Path::resolve())

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Cover the Cache, Logger, Assets, Translations, Languages, Users,
Plugins, Schemes, Site, Templates and Authentication service loaders
through containers built over temporary configuration and files.

Failing tests intentionally document bugs in the sources:
- UsersServiceLoader parses every visible file of the roles and
  accounts directories, so a stray README.md or notes.txt ends in
  "Undefined array key" and takes the whole site down
- PluginsServiceLoader only ignores RuntimeException, a plugin with an
  invalid id raises InvalidArgumentException and breaks the site

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Cover the application singleton, service accessors and error handler, a
smoke test resolving every service declared in services.php, the Site
(page lookup, hidden directories, retrieval, error and index pages,
aliases, modification time, metadata) and UriGenerator.

Failing tests intentionally document bugs in the sources:
- Site::findPage() resolves directories starting with an underscore
  although retrievePages() excludes them from listings, so `_private`
  pages are routable
- UriGenerator::path() resolves `..` against the filesystem root instead
  of the application root

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Exercise the PanelServiceLoader route action listener on every route
defined by the panel: no route is reachable by remote clients before the
first user exists, only registration and assets are reachable from
localhost, only login, logout and assets are reachable without logging
in, logged in users are never redirected and routes outside the panel
root are never touched. Also cover the Http enums.

All tests pass: the gatekeeper does not leave any panel route open.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
Cover the shared controller helpers (view, redirect, redirect to
referer with hostile referers, forward, route generation), the template
and plugin collections and the deprecated ReadonlyModelProperty
attribute.

Failing tests intentionally document bugs in the sources:
- Request::baseUri() and absoluteUri() contain a double slash when the
  application is installed in the document root
  ("http://host//current/"), which makes redirectToReferer() miss the
  "referer is the current page" check

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t49GomL2ddx2h1TeNkLTd
@giuscris
giuscris marked this pull request as ready for review October 13, 2026 16:39
@giuscris
giuscris merged commit 2624ce8 into 2.x Oct 13, 2026
1 check passed
@giuscris
giuscris deleted the tests branch October 13, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants