Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.24% covered (warning)
88.24%
45 / 51
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
TestCase
88.24% covered (warning)
88.24%
45 / 51
70.00% covered (warning)
70.00%
7 / 10
19.59
0.00% covered (danger)
0.00%
0 / 1
 setUp
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 tearDown
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 createApp
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
4.12
 request
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 post
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 serverRequest
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 service
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 database
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 endSession
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Testing;
6
7use Celema\Quma\Database as QumaDatabase;
8use Composer\InstalledVersions;
9use Cosray\App;
10use Cosray\Console\Runtime;
11use Cosray\Exception\RuntimeException;
12use Override;
13use PHPUnit\Framework\TestCase as PHPUnitTestCase;
14use Psr\Http\Message\ResponseInterface as Response;
15use 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 */
25abstract 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}