Skip to content

Commit 556fb68

Browse files
committed
test: cover exception handler and response processors
1 parent 75fd98a commit 556fb68

File tree

9 files changed

+302
-1
lines changed

9 files changed

+302
-1
lines changed

packages/core/src/HasContext.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@ interface HasContext
77
/**
88
* Provides context for the exception-handling pipeline.
99
*/
10-
public function context(): array;
10+
public function context(): iterable;
1111
}

tests/Fixtures/Views/TestViewProcessor.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
use Tempest\View\View;
66
use Tempest\View\ViewProcessor;
77

8+
/**
9+
* Used in TempestViewRendererTest.
10+
*/
811
final readonly class TestViewProcessor implements ViewProcessor
912
{
1013
public function process(View $view): View
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace Tests\Tempest\Integration\Core;
4+
5+
use Tempest\Core\AppConfig;
6+
use Tempest\Core\LogExceptionProcessor;
7+
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
8+
9+
final class ExceptionProcessorTest extends FrameworkIntegrationTestCase
10+
{
11+
public function test_exception_processors_are_discovered(): void
12+
{
13+
$processors = $this->container->get(AppConfig::class)->exceptionProcessors;
14+
15+
$this->assertContains(LogExceptionProcessor::class, $processors);
16+
}
17+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace Tests\Tempest\Integration\Http\Fixtures;
4+
5+
use Exception;
6+
use Tempest\Http\Response;
7+
use Tempest\Http\Responses\Redirect;
8+
use Tempest\Router\Exceptions\SendsResponse;
9+
10+
/**
11+
* Used by HttpExceptionHandlerTest.
12+
*/
13+
final class ExceptionThatSendsRedirectResponse extends Exception implements SendsResponse
14+
{
15+
public function toResponse(): Response
16+
{
17+
return new Redirect('https://tempestphp.com');
18+
}
19+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace Tests\Tempest\Integration\Http\Fixtures;
4+
5+
use Exception;
6+
use Tempest\Core\HasContext;
7+
8+
final class ExceptionWithContext extends Exception implements HasContext
9+
{
10+
public function context(): array
11+
{
12+
return [
13+
'foo' => 'bar',
14+
];
15+
}
16+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace Tests\Tempest\Integration\Http\Fixtures;
4+
5+
use Tempest\Core\ExceptionProcessor;
6+
use Tempest\Discovery\SkipDiscovery;
7+
use Throwable;
8+
9+
#[SkipDiscovery]
10+
final class NullExceptionProcessor implements ExceptionProcessor
11+
{
12+
public static array $exceptions = [];
13+
14+
public function process(Throwable $throwable): Throwable
15+
{
16+
static::$exceptions[] = $throwable;
17+
18+
return $throwable;
19+
}
20+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<?php
2+
3+
namespace Tests\Tempest\Integration\Http;
4+
5+
use Closure;
6+
use Exception;
7+
use PHPUnit\Framework\Attributes\TestWith;
8+
use Tempest\Container\Container;
9+
use Tempest\Core\AppConfig;
10+
use Tempest\Core\FrameworkKernel;
11+
use Tempest\Core\Kernel;
12+
use Tempest\Http\HttpException;
13+
use Tempest\Http\Response;
14+
use Tempest\Http\Responses\Redirect;
15+
use Tempest\Http\Status;
16+
use Tempest\Router\Exceptions\HttpExceptionHandler;
17+
use Tempest\Router\Exceptions\NotFoundException;
18+
use Tempest\Router\ResponseSender;
19+
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
20+
use Tests\Tempest\Integration\Http\Fixtures\ExceptionThatSendsRedirectResponse;
21+
use Tests\Tempest\Integration\Http\Fixtures\ExceptionWithContext;
22+
use Tests\Tempest\Integration\Http\Fixtures\NullExceptionProcessor;
23+
24+
final class HttpExceptionHandlerTest extends FrameworkIntegrationTestCase
25+
{
26+
public ?Response $response = null;
27+
28+
protected function setUp(): void
29+
{
30+
parent::setUp();
31+
32+
$this->container->singleton(
33+
Kernel::class,
34+
fn () => new class($this->container->get(FrameworkKernel::class)) implements Kernel {
35+
public const string VERSION = '1.0.0-alpha.6';
36+
37+
public string $root;
38+
39+
public string $internalStorage;
40+
41+
public array $discoveryLocations;
42+
43+
public array $discoveryClasses;
44+
45+
public Container $container;
46+
47+
public function __construct(FrameworkKernel $kernel)
48+
{
49+
$this->root = $kernel->root;
50+
$this->internalStorage = $kernel->internalStorage;
51+
$this->discoveryLocations = $kernel->discoveryLocations;
52+
$this->discoveryClasses = $kernel->discoveryClasses;
53+
$this->container = $kernel->container;
54+
}
55+
56+
public static function boot(string $root, array $discoveryLocations = [], ?Container $container = null): self
57+
{
58+
// This is just to make static analysis pass, this is never called.
59+
// @mago-expect analysis/undefined-function-or-method
60+
return Kernel::boot($root, $discoveryLocations, $container);
61+
}
62+
63+
public function shutdown(int|string $status = ''): never
64+
{
65+
throw new Exception('Shutdown.');
66+
}
67+
},
68+
);
69+
70+
$this->container->singleton(
71+
ResponseSender::class,
72+
fn () => new class($this) implements ResponseSender {
73+
public function __construct(
74+
private HttpExceptionHandlerTest $case,
75+
) {}
76+
77+
public function send(Response $response): Response
78+
{
79+
$this->case->response = $response;
80+
81+
return $response;
82+
}
83+
},
84+
);
85+
}
86+
87+
public function test_exception_handler_shuts_down_kernel(): void
88+
{
89+
$this->expectExceptionMessage('Shutdown.');
90+
91+
$handler = $this->container->get(HttpExceptionHandler::class);
92+
$handler->handle(new Exception());
93+
}
94+
95+
public function test_exception_handler_sends_response_specified_by_sends_response(): void
96+
{
97+
$this->callExceptionHandler(function (): void {
98+
$handler = $this->container->get(HttpExceptionHandler::class);
99+
$handler->handle(new ExceptionThatSendsRedirectResponse());
100+
});
101+
102+
$this->assertInstanceOf(Redirect::class, $this->response);
103+
$this->assertContains('https://tempestphp.com', $this->response->getHeader('Location')->values);
104+
}
105+
106+
public function test_exception_handler_returns_500_by_default(): void
107+
{
108+
$this->callExceptionHandler(function (): void {
109+
$handler = $this->container->get(HttpExceptionHandler::class);
110+
$handler->handle(new Exception());
111+
});
112+
113+
$this->assertSame(Status::INTERNAL_SERVER_ERROR, $this->response->status);
114+
$this->assertStringContainsString('An unexpected server error occurred', $this->render($this->response->body));
115+
}
116+
117+
public function test_exception_handler_returns_404_for_router_not_found_execption(): void
118+
{
119+
$this->callExceptionHandler(function (): void {
120+
$handler = $this->container->get(HttpExceptionHandler::class);
121+
$handler->handle(new NotFoundException());
122+
});
123+
124+
$this->assertSame(Status::NOT_FOUND, $this->response->status);
125+
$this->assertStringContainsString('This page could not be found on the server', $this->render($this->response->body));
126+
}
127+
128+
#[TestWith([Status::BAD_REQUEST])]
129+
#[TestWith([Status::INTERNAL_SERVER_ERROR])]
130+
#[TestWith([Status::NOT_FOUND])]
131+
#[TestWith([Status::FORBIDDEN])]
132+
#[TestWith([Status::METHOD_NOT_ALLOWED])]
133+
public function test_exception_handler_returns_sane_code_as_http_exception(Status $status): void
134+
{
135+
$this->callExceptionHandler(function () use ($status): void {
136+
$handler = $this->container->get(HttpExceptionHandler::class);
137+
$handler->handle(new HttpException($status));
138+
});
139+
140+
$this->assertSame($status, $this->response->status);
141+
}
142+
143+
public function test_exception_handler_runs_exception_processors(): void
144+
{
145+
$this->container->get(AppConfig::class)->exceptionProcessors[] = NullExceptionProcessor::class;
146+
147+
$thrown = new ExceptionWithContext();
148+
149+
$this->callExceptionHandler(function () use ($thrown): void {
150+
$handler = $this->container->get(HttpExceptionHandler::class);
151+
$handler->handle($thrown);
152+
});
153+
154+
$this->assertContains($thrown, NullExceptionProcessor::$exceptions);
155+
$this->assertArrayHasKey('foo', NullExceptionProcessor::$exceptions[0]->context());
156+
}
157+
158+
private function callExceptionHandler(Closure $callback): void
159+
{
160+
try {
161+
$callback();
162+
} catch (\Throwable $throwable) {
163+
$this->assertSame('Shutdown.', $throwable->getMessage());
164+
}
165+
}
166+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace Tests\Tempest\Integration\Route\Fixtures;
4+
5+
use Exception;
6+
use Tempest\Http\Responses\ServerError;
7+
8+
final class Http500Controller
9+
{
10+
public function basic500(): string
11+
{
12+
throw new Exception('oops');
13+
}
14+
15+
public function custom500(): ServerError
16+
{
17+
return new ServerError('internal server error');
18+
}
19+
}

tests/Integration/Route/RouterTest.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,18 @@
88
use Laminas\Diactoros\Stream;
99
use Laminas\Diactoros\Uri;
1010
use Tempest\Core\AppConfig;
11+
use Tempest\Core\Environment;
1112
use Tempest\Database\Migrations\CreateMigrationsTable;
13+
use Tempest\Http\HttpException;
1214
use Tempest\Http\Responses\Ok;
15+
use Tempest\Http\Responses\ServerError;
1316
use Tempest\Http\Status;
17+
use Tempest\Reflection\MethodReflector;
1418
use Tempest\Router\GenericRouter;
19+
use Tempest\Router\Get;
1520
use Tempest\Router\RouteConfig;
1621
use Tempest\Router\Router;
22+
use Tempest\Router\Routing\Construction\DiscoveredRoute;
1723
use Tests\Tempest\Fixtures\Controllers\ControllerWithEnumBinding;
1824
use Tests\Tempest\Fixtures\Controllers\EnumForController;
1925
use Tests\Tempest\Fixtures\Controllers\TestController;
@@ -24,6 +30,7 @@
2430
use Tests\Tempest\Fixtures\Modules\Books\Models\Author;
2531
use Tests\Tempest\Fixtures\Modules\Books\Models\Book;
2632
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
33+
use Tests\Tempest\Integration\Route\Fixtures\Http500Controller;
2734

2835
use function Tempest\uri;
2936

@@ -266,4 +273,38 @@ public function test_can_add_response_processor(): void
266273
->assertHeaderContains('X-Processed', 'true')
267274
->assertOk();
268275
}
276+
277+
public function test_error_response_processor_throws_http_exceptions_in_production(): void
278+
{
279+
$this->expectException(HttpException::class);
280+
281+
$this->container->get(AppConfig::class)->environment = Environment::PRODUCTION;
282+
$this->http->get('/non-existent');
283+
}
284+
285+
public function test_error_response_processor_throws_http_exceptions_if_there_is_a_body(): void
286+
{
287+
$this->expectException(HttpException::class);
288+
289+
$this->container->get(RouteConfig::class)->staticRoutes['GET']['/returns-basic-500'] = DiscoveredRoute::fromRoute(
290+
new Get('/returns-basic-500'),
291+
MethodReflector::fromParts(Http500Controller::class, 'basic500'),
292+
);
293+
294+
$this->container->get(AppConfig::class)->environment = Environment::PRODUCTION;
295+
$this->http->get('/returns-custom-500');
296+
}
297+
298+
public function test_error_response_processor_does_not_throw_http_exceptions_if_there_is_a_body(): void
299+
{
300+
$this->container->get(RouteConfig::class)->staticRoutes['GET']['/returns-custom-500'] = DiscoveredRoute::fromRoute(
301+
new Get('/returns-custom-500'),
302+
MethodReflector::fromParts(Http500Controller::class, 'custom500'),
303+
);
304+
305+
$this->container->get(AppConfig::class)->environment = Environment::PRODUCTION;
306+
$this->http
307+
->get('/returns-custom-500')
308+
->assertStatus(Status::INTERNAL_SERVER_ERROR);
309+
}
269310
}

0 commit comments

Comments
 (0)