diff --git a/php-packages/testing/.editorconfig b/php-packages/testing/.editorconfig
new file mode 100644
index 000000000..a61a3ab36
--- /dev/null
+++ b/php-packages/testing/.editorconfig
@@ -0,0 +1,19 @@
+# EditorConfig helps developers define and maintain consistent
+# coding styles between different editors and IDEs
+# editorconfig.org
+
+root = true
+
+[*]
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+
+[*.{diff,md}]
+trim_trailing_whitespace = false
+
+[*.{php,xml,json}]
+indent_size = 4
diff --git a/php-packages/testing/.gitattributes b/php-packages/testing/.gitattributes
new file mode 100644
index 000000000..61036520c
--- /dev/null
+++ b/php-packages/testing/.gitattributes
@@ -0,0 +1,15 @@
+.gitattributes export-ignore
+.gitignore export-ignore
+.gitmodules export-ignore
+.github export-ignore
+.travis export-ignore
+.travis.yml export-ignore
+.editorconfig export-ignore
+.styleci.yml export-ignore
+
+phpunit.xml export-ignore
+tests export-ignore
+
+js/dist/* -diff
+
+* text=auto eol=lf
diff --git a/php-packages/testing/.github/workflows/test.yml b/php-packages/testing/.github/workflows/test.yml
new file mode 100644
index 000000000..b7ba28c30
--- /dev/null
+++ b/php-packages/testing/.github/workflows/test.yml
@@ -0,0 +1,83 @@
+name: Tests
+
+on: [push, pull_request]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ defaults:
+ run:
+ shell: bash
+ working-directory: tests
+
+ strategy:
+ matrix:
+ php: [7.3, 7.4, '8.0']
+ service: ['mysql:5.7', mariadb]
+ prefix: ['', flarum_]
+
+ include:
+ - service: 'mysql:5.7'
+ db: MySQL
+ - service: mariadb
+ db: MariaDB
+ - prefix: flarum_
+ prefixStr: (prefix)
+
+ exclude:
+ - php: 7.3
+ service: 'mysql:5.7'
+ prefix: flarum_
+ - php: 7.3
+ service: mariadb
+ prefix: flarum_
+ - php: 8.0
+ service: 'mysql:5.7'
+ prefix: flarum_
+ - php: 8.0
+ service: mariadb
+ prefix: flarum_
+
+ services:
+ mysql:
+ image: ${{ matrix.service }}
+ ports:
+ - 13306:3306
+
+ name: 'PHP ${{ matrix.php }} / ${{ matrix.db }} ${{ matrix.prefixStr }}'
+
+ steps:
+ - uses: actions/checkout@master
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ coverage: xdebug
+ extensions: curl, dom, gd, json, mbstring, openssl, pdo_mysql, tokenizer, zip
+ tools: phpunit, composer:v2
+
+ # The authentication alter is necessary because newer mysql versions use the `caching_sha2_password` driver,
+ # which isn't supported prior to PHP7.4
+ # When we drop support for PHP7.3, we should remove this from the setup.
+ - name: Create MySQL Database
+ run: |
+ sudo systemctl start mysql
+ mysql -uroot -proot -e 'CREATE DATABASE flarum_test;' --port 13306
+ mysql -uroot -proot -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root';" --port 13306
+
+ - name: Install Composer dependencies
+ run: composer install
+
+ - name: Setup Composer tests
+ run: composer test:setup
+ env:
+ DB_PORT: 13306
+ DB_PASSWORD: root
+ DB_PREFIX: ${{ matrix.prefix }}
+
+ - name: Run Composer tests
+ run: composer test
+ env:
+ COMPOSER_PROCESS_TIMEOUT: 600
diff --git a/php-packages/testing/.gitignore b/php-packages/testing/.gitignore
new file mode 100644
index 000000000..38a8fa98d
--- /dev/null
+++ b/php-packages/testing/.gitignore
@@ -0,0 +1,10 @@
+/vendor
+composer.lock
+composer.phar
+node_modules
+.DS_Store
+Thumbs.db
+/src/integration/tmp
+.vagrant
+.idea/*
+.vscode
diff --git a/php-packages/testing/.styleci.yml b/php-packages/testing/.styleci.yml
new file mode 100644
index 000000000..5d07e31ea
--- /dev/null
+++ b/php-packages/testing/.styleci.yml
@@ -0,0 +1,18 @@
+preset: recommended
+
+enabled:
+ - logical_not_operators_with_successor_space
+
+disabled:
+ - align_double_arrow
+ - blank_line_after_opening_tag
+ - multiline_array_trailing_comma
+ - new_with_braces
+ - phpdoc_align
+ - phpdoc_order
+ - phpdoc_separation
+ - phpdoc_types
+
+finder:
+ exclude:
+ - "stubs"
diff --git a/php-packages/testing/LICENSE b/php-packages/testing/LICENSE
new file mode 100755
index 000000000..9fe7ef1c7
--- /dev/null
+++ b/php-packages/testing/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2020-2021 Stichting Flarum (Flarum Foundation)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/php-packages/testing/composer.json b/php-packages/testing/composer.json
new file mode 100644
index 000000000..831dcd88c
--- /dev/null
+++ b/php-packages/testing/composer.json
@@ -0,0 +1,33 @@
+{
+ "name": "flarum/testing",
+ "description": "Automated testing infrastructure for Flarum core and extensions.",
+ "keywords": [
+ "forum",
+ "discussion"
+ ],
+ "homepage": "https://flarum.org/",
+ "license": "MIT",
+ "require": {
+ "ext-json": "*",
+ "mockery/mockery": "^1.4",
+ "phpunit/phpunit": "^9.0"
+ },
+ "require-dev": {
+ "flarum/core": "*@dev"
+ },
+ "autoload": {
+ "psr-4": {
+ "Flarum\\Testing\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Flarum\\Testing\\Tests\\": "src/tests/"
+ }
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ }
+}
diff --git a/php-packages/testing/src/integration/BuildsHttpRequests.php b/php-packages/testing/src/integration/BuildsHttpRequests.php
new file mode 100644
index 000000000..6b379702a
--- /dev/null
+++ b/php-packages/testing/src/integration/BuildsHttpRequests.php
@@ -0,0 +1,69 @@
+withHeader('Content-Type', 'application/json')
+ ->withBody(
+ new CallbackStream(function () use ($json) {
+ return json_encode($json);
+ })
+ );
+ }
+
+ protected function requestAsUser(Request $req, int $userId): Request
+ {
+ $token = Str::random(40);
+
+ /**
+ * We insert this directly instead of via `prepareDatabase`
+ * so that requests can be created/sent after the app is booted.
+ */
+ $this->database()->table('access_tokens')->insert([
+ 'token' => $token,
+ 'user_id' => $userId,
+ 'created_at' => Carbon::now()->toDateTimeString(),
+ 'last_activity_at' => Carbon::now()->toDateTimeString(),
+ 'type' => 'session'
+ ]);
+
+ return $req->withAddedHeader('Authorization', "Token {$token}");
+ }
+
+ protected function requestWithCookiesFrom(Request $req, Response $previous): Request
+ {
+ $cookies = array_reduce(
+ $previous->getHeader('Set-Cookie'),
+ function ($memo, $setCookieString) {
+ $setCookie = SetCookie::fromSetCookieString($setCookieString);
+ $memo[$setCookie->getName()] = $setCookie->getValue();
+
+ return $memo;
+ },
+ []
+ );
+
+ return $req->withCookieParams($cookies);
+ }
+}
diff --git a/php-packages/testing/src/integration/ConsoleTestCase.php b/php-packages/testing/src/integration/ConsoleTestCase.php
new file mode 100644
index 000000000..a29db0895
--- /dev/null
+++ b/php-packages/testing/src/integration/ConsoleTestCase.php
@@ -0,0 +1,44 @@
+console)) {
+ $this->console = new ConsoleApplication('Flarum', Application::VERSION);
+ $this->console->setAutoExit(false);
+
+ foreach ($this->app()->getConsoleCommands() as $command) {
+ $this->console->add($command);
+ }
+ }
+
+ return $this->console;
+ }
+
+ protected function runCommand(array $inputArray)
+ {
+ $input = new ArrayInput($inputArray);
+ $output = new BufferedOutput();
+
+ $this->console()->run($input, $output);
+
+ return trim($output->fetch());
+ }
+}
diff --git a/php-packages/testing/src/integration/Extend/BeginTransactionAndSetDatabase.php b/php-packages/testing/src/integration/Extend/BeginTransactionAndSetDatabase.php
new file mode 100644
index 000000000..cc088686f
--- /dev/null
+++ b/php-packages/testing/src/integration/Extend/BeginTransactionAndSetDatabase.php
@@ -0,0 +1,30 @@
+setDbOnTestCase = $setDbOnTestCase;
+ }
+
+ public function extend(Container $container, Extension $extension = null)
+ {
+ $db = $container->make(ConnectionInterface::class);
+
+ $db->beginTransaction();
+
+ ($this->setDbOnTestCase)($db);
+ }
+}
diff --git a/php-packages/testing/src/integration/Extend/OverrideExtensionManagerForTests.php b/php-packages/testing/src/integration/Extend/OverrideExtensionManagerForTests.php
new file mode 100644
index 000000000..8d66e7319
--- /dev/null
+++ b/php-packages/testing/src/integration/Extend/OverrideExtensionManagerForTests.php
@@ -0,0 +1,39 @@
+extensions = $extensions;
+ }
+
+ public function extend(Container $container, Extension $extension = null)
+ {
+ $container->when(ExtensionManagerIncludeCurrent::class)->needs('$enabledIds')->give($this->extensions);
+ if (count($this->extensions)) {
+ $container->singleton(ExtensionManager::class, ExtensionManagerIncludeCurrent::class);
+ $extensionManager = $container->make(ExtensionManager::class);
+
+ foreach ($this->extensions as $extension) {
+ $extensionManager->enable($extension);
+ }
+
+ $extensionManager->booted = true;
+
+ $extensionManager->extend($container);
+ }
+ }
+}
diff --git a/php-packages/testing/src/integration/Extend/SetSettingsBeforeBoot.php b/php-packages/testing/src/integration/Extend/SetSettingsBeforeBoot.php
new file mode 100644
index 000000000..51957b19a
--- /dev/null
+++ b/php-packages/testing/src/integration/Extend/SetSettingsBeforeBoot.php
@@ -0,0 +1,32 @@
+settings = $settings;
+ }
+
+ public function extend(Container $container, Extension $extension = null)
+ {
+ if (count($this->settings)) {
+ $settings = $container->make(SettingsRepositoryInterface::class);
+
+ foreach ($this->settings as $key => $value) {
+ $settings->set($key, $value);
+ }
+ }
+ }
+}
diff --git a/php-packages/testing/src/integration/Extension/ExtensionManagerIncludeCurrent.php b/php-packages/testing/src/integration/Extension/ExtensionManagerIncludeCurrent.php
new file mode 100644
index 000000000..1a3dbc8f0
--- /dev/null
+++ b/php-packages/testing/src/integration/Extension/ExtensionManagerIncludeCurrent.php
@@ -0,0 +1,113 @@
+enabledIds = $enabledIds;
+ }
+
+ /**
+ * @{@inheritDoc}
+ */
+ public function getExtensions()
+ {
+ $extensions = parent::getExtensions();
+
+ $package = json_decode($this->filesystem->get($this->paths->vendor . '/../composer.json'), true);
+
+ if (Arr::get($package, 'type') === 'flarum-extension') {
+ $current = new Extension($this->paths->vendor . '/../', $package);
+ $current->setInstalled(true);
+ $current->setVersion(Arr::get($package, 'version'));
+ $current->calculateDependencies([], []);
+
+ $extensions->put($current->getId(), $current);
+
+ $this->extensions = $extensions->sortBy(function ($extension, $name) {
+ return $extension->composerJsonAttribute('extra.flarum-extension.title');
+ });
+ }
+
+ return $this->extensions;
+ }
+
+ /**
+ * We assume it's not enabled during boot.
+ * However, since some logic needs this, as soon as we enable extensions
+ * we'll switch booted to on.
+ */
+ public function isEnabled($extension)
+ {
+ if (!$this->booted) return false;
+
+ return parent::isEnabled($extension);
+ }
+
+ /**
+ * In test cases, enabled extensions are determined by the test case, not the database.
+ */
+ public function getEnabled()
+ {
+ return $this->enabledIds;
+ }
+
+ /**
+ * Enabled extensions must be specified by the test case, so this should do nothing.
+ */
+ protected function setEnabledExtensions(array $enabledExtensions)
+ {
+ }
+
+ /**
+ * Get an instance of the assets filesystem.
+ * This is resolved dynamically because Flarum's filesystem configuration
+ * might not be booted yet when the ExtensionManager singleton initializes.
+ */
+ protected function getAssetsFilesystem(): Cloud
+ {
+ return new FilesystemAdapter(new FlysystemFilesystem(new Local($this->paths->public . '/assets'), ['url' => resolve('flarum.config')->url() . '/assets']));
+ }
+}
diff --git a/php-packages/testing/src/integration/RetrievesAuthorizedUsers.php b/php-packages/testing/src/integration/RetrievesAuthorizedUsers.php
new file mode 100644
index 000000000..8eefc75f6
--- /dev/null
+++ b/php-packages/testing/src/integration/RetrievesAuthorizedUsers.php
@@ -0,0 +1,24 @@
+ 2,
+ 'username' => 'normal',
+ 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', // BCrypt hash for "too-obscure"
+ 'email' => 'normal@machine.local',
+ 'is_email_confirmed' => 1,
+ ];
+ }
+}
diff --git a/php-packages/testing/src/integration/Setup/SetupScript.php b/php-packages/testing/src/integration/Setup/SetupScript.php
new file mode 100644
index 000000000..0767db596
--- /dev/null
+++ b/php-packages/testing/src/integration/Setup/SetupScript.php
@@ -0,0 +1,149 @@
+host = getenv('DB_HOST') ?: 'localhost';
+ $this->port = intval(getenv('DB_PORT') ?: 3306);
+ $this->name = getenv('DB_DATABASE') ?: 'flarum_test';
+ $this->user = getenv('DB_USERNAME') ?: 'root';
+ $this->pass = getenv('DB_PASSWORD') ?? 'root';
+ $this->pref = getenv('DB_PREFIX') ?: '';
+ }
+
+ public function run()
+ {
+ $tmp = $this->tmpDir();
+
+ echo "Connecting to database $this->name at $this->host:$this->port.\n";
+ echo "Warning: all tables will be dropped to ensure clean state. DO NOT use your production database!\n";
+ echo "Logging in as $this->user with password '$this->pass'.\n";
+ echo "Table prefix: '$this->pref'\n";
+ echo "\nStoring test config in '$tmp'\n";
+
+ echo "\n\nCancel now if that's not what you want...\n";
+ echo "Use the following environment variables for configuration:\n";
+ echo "Database: DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_PREFIX\n";
+ echo "Test Config: FLARUM_TEST_TMP_DIR or FLARUM_TEST_TMP_DIR_LOCAL\n";
+
+
+ sleep(4);
+
+ echo "\nOff we go...\n";
+
+ $this->dbConfig = new DatabaseConfig('mysql', $this->host, $this->port, $this->name, $this->user, $this->pass, $this->pref);
+
+ echo "\nWiping DB to ensure clean state\n";
+ $this->wipeDb();
+ echo "Success! Proceeding to installation...\n";
+
+ $this->setupTmpDir();
+
+ $installation = new Installation(
+ new Paths([
+ 'base' => $tmp,
+ 'public' => "$tmp/public",
+ 'storage' => "$tmp/storage",
+ 'vendor' => getcwd() . '/vendor',
+ ])
+ );
+
+ $pipeline = $installation
+ ->configPath('config.php')
+ ->debugMode(true)
+ ->baseUrl(BaseUrl::fromString('http://localhost'))
+ ->databaseConfig($this->dbConfig)
+ ->adminUser(new AdminUser(
+ 'admin',
+ 'password',
+ 'admin@machine.local'
+ ))
+ ->settings(['mail_driver' => 'log'])
+ ->extensions([])
+ ->build();
+
+ // Run the actual configuration
+ $pipeline->run();
+
+ echo "Installation complete\n";
+ }
+
+ protected function wipeDb()
+ {
+ // Reuse the connection step to include version checks
+ (new ConnectToDatabase($this->dbConfig, function ($db) {
+ // Inspired by Laravel's db:wipe
+ $builder = $db->getSchemaBuilder();
+
+ $builder->dropAllTables();
+ $builder->dropAllViews();
+ }))->run();
+ }
+}
diff --git a/php-packages/testing/src/integration/TestCase.php b/php-packages/testing/src/integration/TestCase.php
new file mode 100644
index 000000000..034db0f3f
--- /dev/null
+++ b/php-packages/testing/src/integration/TestCase.php
@@ -0,0 +1,295 @@
+database()->rollBack();
+ }
+
+ /**
+ * @var \Flarum\Foundation\InstalledApp
+ */
+ protected $app;
+
+ /**
+ * @return \Flarum\Foundation\InstalledApp
+ */
+ protected function app()
+ {
+ if (is_null($this->app)) {
+ $tmp = $this->tmpDir();
+
+ $config = include "$tmp/config.php";
+
+ foreach ($this->config as $key => $value) {
+ Arr::set($config, $key, $value);
+ }
+
+ $site = new InstalledSite(
+ new Paths([
+ 'base' => $tmp,
+ 'public' => "$tmp/public",
+ 'storage' => "$tmp/storage",
+ 'vendor' => getcwd().'/vendor',
+ ]),
+ new Config($config)
+ );
+
+ $extenders = array_merge([
+ new OverrideExtensionManagerForTests($this->extensions),
+ new BeginTransactionAndSetDatabase(function (ConnectionInterface $db) {
+ $this->database = $db;
+ }),
+ new SetSettingsBeforeBoot($this->settings),
+ ], $this->extenders);
+
+ $site->extendWith($extenders);
+
+ $this->app = $site->bootApp();
+
+ $this->populateDatabase();
+ }
+
+ return $this->app;
+ }
+
+ /**
+ * @var ExtenderInterface[]
+ */
+ protected $extenders = [];
+
+ /**
+ * Each argument should be an instance of an extender that should
+ * be applied at application boot.
+ *
+ * Note that this method will have no effect if called after the
+ * application is booted.
+ */
+ protected function extend(ExtenderInterface ...$extenders)
+ {
+ $this->extenders = array_merge($this->extenders, $extenders);
+ }
+
+ /**
+ * @var string[]
+ */
+ protected $extensions = [];
+
+ /**
+ * Each argument should be an ID of an extension to be enabled.
+ * Extensions other than the one currently being tested must be
+ * listed in this extension's `composer.json` under `require` or
+ * `require-dev`.
+ *
+ * Note that this method will have no effect if called after the
+ * application is booted.
+ */
+ protected function extension(string ...$extensions)
+ {
+ $this->extensions = array_merge($this->extensions, $extensions);
+ }
+
+ /**
+ * @var array
+ */
+ protected $config = [];
+
+ /**
+ * Some Flarum code depends on config.php values. Flarum doesn't
+ * offer a way to set them at runtime, so this method lets you
+ * add/override them before boot.
+ *
+ * You can use dot-separated syntax to assign values to subarrays.
+ *
+ * For example:
+ *
+ * `$this->config('a.b.c', 'value');` will result in the following:
+ *
+ * [
+ * 'a' => [
+ * 'b' => ['c' => 'value']
+ * ]
+ * ]
+ *
+ * Note that this method will have no effect if called after the
+ * application is booted.
+ */
+ protected function config(string $key, $value)
+ {
+ $this->config[$key] = $value;
+ }
+
+ /**
+ * @var array
+ */
+ protected $settings = [];
+
+ /**
+ * Some settings are used during application boot, so setting
+ * them via `prepareDatabase` will be too late for the desired
+ * effect. For instance, in core the active display name driver
+ * is configured based on the `display_name_driver` setting.
+ * That setting should be registered using this method.
+ *
+ * Note that this method will have no effect if called after the
+ * application is booted.
+ */
+ protected function setting(string $key, $value)
+ {
+ $this->settings[$key] = $value;
+ }
+
+ /**
+ * @var RequestHandlerInterface
+ */
+ protected $server;
+
+ protected function server(): RequestHandlerInterface
+ {
+ if (is_null($this->server)) {
+ $this->server = $this->app()->getRequestHandler();
+ }
+
+ return $this->server;
+ }
+
+ protected $database;
+
+ protected function database(): ConnectionInterface
+ {
+ $this->app();
+ // Set in `BeginTransactionAndSetDatabase` extender.
+ return $this->database;
+ }
+
+ protected $databaseContent = [];
+
+ protected function prepareDatabase(array $tableData)
+ {
+ $this->databaseContent = array_merge_recursive(
+ $this->databaseContent,
+ $tableData
+ );
+ }
+
+ protected function populateDatabase()
+ {
+ // We temporarily disable foreign key checks to simplify this process.
+ $this->database()->getSchemaBuilder()->disableForeignKeyConstraints();
+
+ // Then, insert all rows required for this test case.
+ foreach ($this->databaseContent as $table => $rows) {
+ foreach ($rows as $row) {
+ if ($table === 'settings') {
+ $this->database()->table($table)->updateOrInsert(
+ ['key' => $row['key']],
+ $row
+ );
+ } else {
+ $this->database()->table($table)->updateOrInsert(
+ isset($row['id']) ? ['id' => $row['id']] : $row,
+ $row
+ );
+ }
+ }
+ }
+
+ // And finally, turn on foreign key checks again.
+ $this->database()->getSchemaBuilder()->enableForeignKeyConstraints();
+ }
+
+ /**
+ * Send a full HTTP request through Flarum's middleware stack.
+ */
+ protected function send(ServerRequestInterface $request): ResponseInterface
+ {
+ return $this->server()->handle($request);
+ }
+
+ /**
+ * Build a HTTP request that can be passed through middleware.
+ *
+ * This method simplifies building HTTP requests for use in our HTTP-level
+ * integration tests. It provides options for all features repeatedly being
+ * used in those tests.
+ *
+ * @param string $method
+ * @param string $path
+ * @param array $options
+ * An array of optional request properties.
+ * Currently supported:
+ * - "json" should point to a JSON-serializable object that will be
+ * serialized and used as request body. The corresponding Content-Type
+ * header will be set automatically.
+ * - "authenticatedAs" should identify an *existing* user by ID. This will
+ * cause an access token to be created for this user, which will be used
+ * to authenticate the request via the "Authorization" header.
+ * - "cookiesFrom" should hold a response object from a previous HTTP
+ * interaction. All cookies returned from the server in that response
+ * (via the "Set-Cookie" header) will be copied to the cookie params of
+ * the new request.
+ * @return ServerRequestInterface
+ */
+ protected function request(string $method, string $path, array $options = []): ServerRequestInterface
+ {
+ $request = new ServerRequest([], [], $path, $method);
+
+ // Do we want a JSON request body?
+ if (isset($options['json'])) {
+ $request = $this->requestWithJsonBody(
+ $request,
+ $options['json']
+ );
+ }
+
+ // Authenticate as a given user
+ if (isset($options['authenticatedAs'])) {
+ $request = $this->requestAsUser(
+ $request,
+ $options['authenticatedAs']
+ );
+ }
+
+ // Let's copy the cookies from a previous response
+ if (isset($options['cookiesFrom'])) {
+ $request = $this->requestWithCookiesFrom(
+ $request,
+ $options['cookiesFrom']
+ );
+ }
+
+ return $request;
+ }
+}
diff --git a/php-packages/testing/src/integration/UsesTmpDir.php b/php-packages/testing/src/integration/UsesTmpDir.php
new file mode 100644
index 000000000..23f2eb279
--- /dev/null
+++ b/php-packages/testing/src/integration/UsesTmpDir.php
@@ -0,0 +1,44 @@
+ '{}'
+ ];
+
+ $tmpDir = $this->tmpDir();
+
+ foreach ($DIRS_NEEDED as $path) {
+ $fullPath = $tmpDir.$path;
+ if (!file_exists($fullPath)) {
+ mkdir($fullPath);
+ }
+ }
+
+ foreach ($FILES_NEEDED as $path => $contents) {
+ $fullPath = $tmpDir.$path;
+ if (!file_exists($fullPath)) {
+ file_put_contents($fullPath, $contents);
+ }
+ }
+ }
+}
diff --git a/php-packages/testing/src/unit/TestCase.php b/php-packages/testing/src/unit/TestCase.php
new file mode 100644
index 000000000..ae04b4cac
--- /dev/null
+++ b/php-packages/testing/src/unit/TestCase.php
@@ -0,0 +1,18 @@
+serializeToForum('notARealSetting', 'not.a.real.setting'),
+ (new Extend\Frontend('forum'))->route('/added-by-extension', 'added-by-extension')
+];
diff --git a/php-packages/testing/tests/migrations/2021_05_11_000000_create_table_so_we_can_check_existence.php b/php-packages/testing/tests/migrations/2021_05_11_000000_create_table_so_we_can_check_existence.php
new file mode 100644
index 000000000..649dff1b9
--- /dev/null
+++ b/php-packages/testing/tests/migrations/2021_05_11_000000_create_table_so_we_can_check_existence.php
@@ -0,0 +1,10 @@
+string('id', 100)->primary();
+});
\ No newline at end of file
diff --git a/php-packages/testing/tests/tests/.phpunit.result.cache b/php-packages/testing/tests/tests/.phpunit.result.cache
new file mode 100644
index 000000000..05fbec964
--- /dev/null
+++ b/php-packages/testing/tests/tests/.phpunit.result.cache
@@ -0,0 +1 @@
+C:37:"PHPUnit\Runner\DefaultTestResultCache":219:{a:2:{s:7:"defects";a:1:{s:74:"Flarum\Testing\Tests\integration\TestCaseTest::can_add_settings_via_method";i:4;}s:5:"times";a:1:{s:74:"Flarum\Testing\Tests\integration\TestCaseTest::can_add_settings_via_method";d:0.09;}}}
\ No newline at end of file
diff --git a/php-packages/testing/tests/tests/fixtures/.gitkeep b/php-packages/testing/tests/tests/fixtures/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/php-packages/testing/tests/tests/integration/TestCaseTest.php b/php-packages/testing/tests/tests/integration/TestCaseTest.php
new file mode 100644
index 000000000..2b7c3814e
--- /dev/null
+++ b/php-packages/testing/tests/tests/integration/TestCaseTest.php
@@ -0,0 +1,211 @@
+app();
+
+ $this->assertEquals(1, User::query()->count());
+
+ $user = User::find(1);
+
+ $this->assertEquals('admin', $user->username);
+ $this->assertEquals('admin@machine.local', $user->email);
+ $this->assertTrue($user->isAdmin());
+ }
+
+ /**
+ * @test
+ */
+ public function can_add_settings_via_method()
+ {
+ $this->setting('hello', 'world');
+ $this->setting('display_name_driver', 'something_other_than_username');
+
+ $settings = $this->app()->getContainer()->make(SettingsRepositoryInterface::class);
+
+ $this->assertEquals('world', $settings->get('hello'));
+ $this->assertEquals('something_other_than_username', $settings->get('display_name_driver'));
+ }
+
+ /**
+ * @test
+ */
+ public function settings_cleaned_up_from_previous_method()
+ {
+ $settings = $this->app()->getContainer()->make(SettingsRepositoryInterface::class);
+
+ $this->assertEquals(null, $settings->get('hello'));
+ $this->assertEquals(null, $settings->get('display_name_driver'));
+ }
+
+ /**
+ * @test
+ */
+ public function can_add_config_via_method()
+ {
+ $this->config('hello', 'world');
+ $this->config('url', 'https://flarum.org');
+ $this->config('level1.level2', 'value');
+
+ $config = $this->app()->getContainer()->make(Config::class);
+
+ $this->assertEquals('world', $config['hello']);
+ $this->assertEquals('https://flarum.org', $config['url']);
+ $this->assertEquals('value', $config['level1']['level2']);
+ }
+
+ /**
+ * @test
+ */
+ public function config_cleaned_up_from_previous_method()
+ {
+ $config = $this->app()->getContainer()->make(Config::class);
+
+ $this->assertEquals(null, $config['hello']);
+ $this->assertEquals('http://localhost', $config['url']);
+ $this->assertFalse(isset($config['level1']['level2']));
+ }
+
+ /**
+ * @test
+ */
+ public function current_extension_not_applied_by_default()
+ {
+ $response = $this->send(
+ $this->request('GET', '/')
+ );
+
+ $this->assertStringNotContainsString('notARealSetting', $response->getBody()->getContents());
+ }
+
+ /**
+ * @test
+ */
+ public function current_extension_applied_if_specified()
+ {
+ $this->extension('flarum-testing-tests');
+
+ $response = $this->send(
+ $this->request('GET', '/')
+ );
+
+ $this->assertStringContainsString('notARealSetting', $response->getBody()->getContents());
+ }
+
+ /**
+ * @test
+ */
+ public function current_extension_migrations_applied_if_specified()
+ {
+ $this->extension('flarum-testing-tests');
+
+ $tableExists = $this->app()->getContainer()->make(Builder::class)->hasTable('testing_table');
+ $this->assertTrue($tableExists);
+ }
+
+ /**
+ * @test
+ */
+ public function current_extension_considered_enabled_after_boot()
+ {
+ $this->extension('flarum-testing-tests');
+
+ $enabled = $this->app()->getContainer()->make('flarum.extensions')->isEnabled('flarum-testing-tests');
+ $this->assertTrue($enabled);
+ }
+
+ /**
+ * @test
+ */
+ public function can_apply_extenders()
+ {
+ $this->extend(
+ (new Extend\Settings)->serializeToForum('notARealSetting', 'not.a.real.setting')
+ );
+
+ $response = $this->send(
+ $this->request('GET', '/')
+ );
+
+ $this->assertStringContainsString('notARealSetting', $response->getBody()->getContents());
+ }
+
+ /**
+ * @test
+ */
+ public function can_apply_route_extenders()
+ {
+ $this->extend(
+ (new Extend\Frontend('forum'))->route('/arbitrary', 'arbitrary')
+ );
+
+ $response = $this->send(
+ $this->request('GET', '/arbitrary')
+ );
+
+ $this->assertEquals(200, $response->getStatusCode());
+ }
+
+ /**
+ * @test
+ */
+ public function routes_added_by_current_extension_not_accessible_by_default()
+ {
+ $response = $this->send(
+ $this->request('GET', '/added-by-extension')
+ );
+
+ $this->assertEquals(404, $response->getStatusCode());
+ }
+
+ /**
+ * @test
+ */
+ public function routes_added_by_current_extension_accessible()
+ {
+ $this->extension('flarum-testing-tests');
+
+ $response = $this->send(
+ $this->request('GET', '/added-by-extension')
+ );
+
+ $this->assertEquals(200, $response->getStatusCode());
+ }
+
+ /**
+ * @test
+ */
+ public function extension_url_correct()
+ {
+ $this->extension('flarum-testing-tests');
+ $expected = $this->app()->getContainer()->make('filesystem')->disk('flarum-assets')->url('/flarum-testing-tests/');
+ // We need to test this since we override it.
+ $extensions = $this->app()->getContainer()->make('flarum.extensions');
+ $currExtension = $extensions->getExtension('flarum-testing-tests');
+ $baseAssetsUrl = $extensions->getAsset($currExtension, '');
+
+ $this->assertEquals($expected, $baseAssetsUrl);
+ }
+}
\ No newline at end of file
diff --git a/php-packages/testing/tests/tests/integration/setup.php b/php-packages/testing/tests/tests/integration/setup.php
new file mode 100644
index 000000000..67039c083
--- /dev/null
+++ b/php-packages/testing/tests/tests/integration/setup.php
@@ -0,0 +1,16 @@
+run();
diff --git a/php-packages/testing/tests/tests/phpunit.integration.xml b/php-packages/testing/tests/tests/phpunit.integration.xml
new file mode 100644
index 000000000..23afc237d
--- /dev/null
+++ b/php-packages/testing/tests/tests/phpunit.integration.xml
@@ -0,0 +1,24 @@
+
+
+
+
+ ../src/
+
+
+
+
+ ./integration
+
+
+
diff --git a/php-packages/testing/tests/tests/phpunit.unit.xml b/php-packages/testing/tests/tests/phpunit.unit.xml
new file mode 100644
index 000000000..d3a4a3e3d
--- /dev/null
+++ b/php-packages/testing/tests/tests/phpunit.unit.xml
@@ -0,0 +1,27 @@
+
+
+
+
+ ../src/
+
+
+
+
+ ./unit
+
+
+
+
+
+
diff --git a/php-packages/testing/tests/tests/unit/.gitkeep b/php-packages/testing/tests/tests/unit/.gitkeep
new file mode 100644
index 000000000..e69de29bb