Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
88.24% |
45 / 51 |
|
70.00% |
7 / 10 |
CRAP | |
0.00% |
0 / 1 |
| TestCase | |
88.24% |
45 / 51 |
|
70.00% |
7 / 10 |
19.59 | |
0.00% |
0 / 1 |
| setUp | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
2.01 | |||
| tearDown | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| createApp | |
50.00% |
4 / 8 |
|
0.00% |
0 / 1 |
4.12 | |||
| request | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| get | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| post | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| serverRequest | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| service | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
| database | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| endSession | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Testing; |
| 6 | |
| 7 | use Celema\Quma\Database as QumaDatabase; |
| 8 | use Composer\InstalledVersions; |
| 9 | use Cosray\App; |
| 10 | use Cosray\Console\Runtime; |
| 11 | use Cosray\Exception\RuntimeException; |
| 12 | use Override; |
| 13 | use PHPUnit\Framework\TestCase as PHPUnitTestCase; |
| 14 | use Psr\Http\Message\ResponseInterface as Response; |
| 15 | use Psr\Http\Message\ServerRequestInterface as Request; |
| 16 | |
| 17 | /** |
| 18 | * Base class for a site's tests. Each test gets a new instance of the site's |
| 19 | * app, whose database work happens in a transaction that is rolled back |
| 20 | * after the test. Requests run no jobs after their response: queued mails |
| 21 | * stay in the queue for the test to read and are never sent. |
| 22 | * |
| 23 | * @api |
| 24 | */ |
| 25 | abstract class TestCase extends PHPUnitTestCase |
| 26 | { |
| 27 | protected App $app; |
| 28 | private ?Database $db = null; |
| 29 | private ?Runtime $runtime = null; |
| 30 | |
| 31 | #[Override] |
| 32 | protected function setUp(): void |
| 33 | { |
| 34 | parent::setUp(); |
| 35 | |
| 36 | $this->app = Bootstrap::within($this->createApp(...))->boot(); |
| 37 | $db = $this->app->container()->get(QumaDatabase::class); |
| 38 | |
| 39 | if (!$db instanceof Database) { |
| 40 | throw new RuntimeException('createApp() must create the app it returns, not reuse one.'); |
| 41 | } |
| 42 | |
| 43 | $db->isolate(); |
| 44 | $this->db = $db; |
| 45 | } |
| 46 | |
| 47 | #[Override] |
| 48 | protected function tearDown(): void |
| 49 | { |
| 50 | try { |
| 51 | // Restores the translator the scope activated. |
| 52 | $this->runtime = null; |
| 53 | $this->db?->discard(); |
| 54 | $this->db = null; |
| 55 | self::endSession(); |
| 56 | } finally { |
| 57 | parent::tearDown(); |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | /** |
| 62 | * Creates the site's app, anew for each test. By default it returns what |
| 63 | * the project's `boot/app.php` returns. |
| 64 | */ |
| 65 | protected function createApp(): App |
| 66 | { |
| 67 | $root = realpath(InstalledVersions::getRootPackage()['install_path']); |
| 68 | $file = "{$root}/boot/app.php"; |
| 69 | |
| 70 | if (!is_file($file)) { |
| 71 | throw new RuntimeException("{$file} does not exist; override createApp() to create the site's app."); |
| 72 | } |
| 73 | |
| 74 | $app = require $file; |
| 75 | |
| 76 | if (!$app instanceof App) { |
| 77 | throw new RuntimeException("{$file} must return the site's Cosray\\App."); |
| 78 | } |
| 79 | |
| 80 | return $app; |
| 81 | } |
| 82 | |
| 83 | /** Handles the request as the site would, without sending the response. */ |
| 84 | protected function request(Request $request): Response |
| 85 | { |
| 86 | // PHPUnit lowers error_reporting while a test runs, and the app's error |
| 87 | // handler ignores what it does not report, unlike on the server. |
| 88 | $reporting = error_reporting(E_ALL); |
| 89 | |
| 90 | try { |
| 91 | return $this->database()->request(fn(): Response => $this->app->core()->handle($request)); |
| 92 | } finally { |
| 93 | error_reporting($reporting); |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | /** @param array<string, string|list<string>> $headers */ |
| 98 | protected function get(string $uri, array $headers = []): Response |
| 99 | { |
| 100 | return $this->request($this->serverRequest('GET', $uri, $headers)); |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * Posts the form as a browser does. |
| 105 | * |
| 106 | * @param array<array-key, mixed> $form |
| 107 | * @param array<string, string|list<string>> $headers |
| 108 | */ |
| 109 | protected function post(string $uri, array $form = [], array $headers = []): Response |
| 110 | { |
| 111 | $request = $this |
| 112 | ->serverRequest('POST', $uri, ['Content-Type' => 'application/x-www-form-urlencoded', ...$headers]) |
| 113 | ->withParsedBody($form) |
| 114 | ->withBody($this->app->factory()->streamFactory()->createStream(http_build_query($form))); |
| 115 | |
| 116 | return $this->request($request); |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * A request from 127.0.0.1, to `http://localhost` unless the URI names a host. |
| 121 | * |
| 122 | * @param array<string, string|list<string>> $headers |
| 123 | */ |
| 124 | protected function serverRequest(string $method, string $uri, array $headers = []): Request |
| 125 | { |
| 126 | if (!str_contains($uri, '://')) { |
| 127 | $uri = 'http://localhost/' . ltrim($uri, '/'); |
| 128 | } |
| 129 | |
| 130 | $request = $this->app->factory()->serverRequestFactory()->createServerRequest($method, $uri, [ |
| 131 | 'REMOTE_ADDR' => '127.0.0.1', |
| 132 | ]); |
| 133 | parse_str($request->getUri()->getQuery(), $query); |
| 134 | $request = $request->withQueryParams($query); |
| 135 | |
| 136 | foreach ($headers as $name => $value) { |
| 137 | $request = $request->withHeader($name, $value); |
| 138 | } |
| 139 | |
| 140 | return $request; |
| 141 | } |
| 142 | |
| 143 | /** |
| 144 | * A service of the app as a job or a console command gets it: without a |
| 145 | * request, in the default locale. |
| 146 | * |
| 147 | * @template T of object |
| 148 | * |
| 149 | * @param class-string<T> $class |
| 150 | * @return T |
| 151 | */ |
| 152 | protected function service(string $class): object |
| 153 | { |
| 154 | $this->runtime ??= new Runtime($this->app->container()); |
| 155 | $service = $this->runtime->get($class); |
| 156 | |
| 157 | if (!$service instanceof $class) { |
| 158 | throw new RuntimeException("Resolving {$class} returned " . get_debug_type($service) . '.'); |
| 159 | } |
| 160 | |
| 161 | return $service; |
| 162 | } |
| 163 | |
| 164 | private function database(): Database |
| 165 | { |
| 166 | return $this->db ?? throw new RuntimeException('The test case has not set up the app.'); |
| 167 | } |
| 168 | |
| 169 | /** |
| 170 | * A request closes its session, and the CLI keeps the id for the next |
| 171 | * session_start(); reopen it so it can be destroyed. |
| 172 | */ |
| 173 | private static function endSession(): void |
| 174 | { |
| 175 | if (session_status() === PHP_SESSION_NONE && session_id() !== '') { |
| 176 | session_start(); |
| 177 | } |
| 178 | |
| 179 | if (session_status() === PHP_SESSION_ACTIVE) { |
| 180 | $_SESSION = []; |
| 181 | session_unset(); |
| 182 | session_destroy(); |
| 183 | } |
| 184 | } |
| 185 | } |