feat: reload the page with a flash when an htmx request fails
This commit is contained in:
1 parent
0be3c2ccde
commit
a2e4035889
14 files changed
+205
-210
No files matched your search
@@ -23,3 +23,11 @@ htmx.config.responseHandling = [
|
|||||||
{ code: '422', swap: true, error: false },
|
{ code: '422', swap: true, error: false },
|
||||||
{ code: '[45]..', swap: false, error: true },
|
{ code: '[45]..', swap: false, error: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Anything else that fails -- a 4xx other than 422, a 5xx, or no response at all -- reloads the
|
||||||
|
// page. htmx only opens modals and refreshes fragments, so there is always a whole page underneath,
|
||||||
|
// and every page is a GET: reloading is safe, closes the modal and shows current data. The server
|
||||||
|
// keeps honest status codes and leaves a flash explaining the reload (HtmxErrorFlashListener).
|
||||||
|
const reloadPage = () => window.location.reload();
|
||||||
|
document.addEventListener('htmx:responseError', reloadPage);
|
||||||
|
document.addEventListener('htmx:sendError', reloadPage);
|
||||||
@@ -10,7 +10,8 @@ security:
|
|||||||
|
|
||||||
firewalls:
|
firewalls:
|
||||||
dev:
|
dev:
|
||||||
pattern: ^/(_profiler|_wdt|assets)/
|
# _error is the dev-only preview of the error pages (/_error/404, /_error/500).
|
||||||
|
pattern: ^/(_profiler|_wdt|_error|assets)/
|
||||||
security: false
|
security: false
|
||||||
main:
|
main:
|
||||||
lazy: true
|
lazy: true
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\EventListener;
|
||||||
|
|
||||||
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
|
||||||
|
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||||
|
use Symfony\Component\HttpKernel\KernelEvents;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leaves a flash behind when an htmx request fails, for the page reload that follows.
|
||||||
|
*
|
||||||
|
* htmx is only used for modals and out-of-band refreshes, so there is always a whole page under a
|
||||||
|
* failed request -- and every page is a GET. assets/app.js therefore answers a failed htmx request
|
||||||
|
* by reloading that page, and this flash is what tells the user why it just reloaded.
|
||||||
|
*
|
||||||
|
* It only adds the flash. The response is left alone, so it keeps its real status and Symfony
|
||||||
|
* logs the exception as usual. A listener that already produced a response stops propagation
|
||||||
|
* before this runs -- notably the firewall's entry point, which sends an expired session to the
|
||||||
|
* login page with HX-Redirect instead.
|
||||||
|
*/
|
||||||
|
#[AsEventListener(event: KernelEvents::EXCEPTION, priority: -10)]
|
||||||
|
final class HtmxErrorFlashListener
|
||||||
|
{
|
||||||
|
public function __invoke(ExceptionEvent $event): void
|
||||||
|
{
|
||||||
|
$request = $event->getRequest();
|
||||||
|
|
||||||
|
if (!$request->headers->has('HX-Request') || !$request->hasSession()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = $request->getSession();
|
||||||
|
|
||||||
|
if ($session instanceof FlashBagAwareSessionInterface) {
|
||||||
|
$session->getFlashBag()->add('error', 'Da ist etwas schiefgelaufen. Bitte versuche es erneut.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\EventListener;
|
|
||||||
|
|
||||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
|
||||||
use Symfony\Component\HttpKernel\KernelEvents;
|
|
||||||
use Twig\Environment;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders an error modal when an htmx request blows up.
|
|
||||||
*
|
|
||||||
* htmx does not swap a 5xx by default, so an uncaught exception on a fragment request produces
|
|
||||||
* no visible effect whatsoever -- the user clicks and nothing happens. This turns it into
|
|
||||||
* something they can see: a 200 carrying the error modal, retargeted at <body> so it appends
|
|
||||||
* regardless of what the original hx-target was.
|
|
||||||
*
|
|
||||||
* allowCustomResponseCode() is what keeps the 200; without it the kernel rewrites it to 500 and
|
|
||||||
* htmx discards the body again.
|
|
||||||
*/
|
|
||||||
#[AsEventListener(event: KernelEvents::EXCEPTION, priority: -10)]
|
|
||||||
final readonly class HtmxExceptionListener
|
|
||||||
{
|
|
||||||
public function __construct(private Environment $twig)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function __invoke(ExceptionEvent $event): void
|
|
||||||
{
|
|
||||||
if (!$event->getRequest()->headers->has('HX-Request')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($event->hasResponse()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = new Response($this->twig->render('htmx_error_modal.html.twig'), Response::HTTP_OK);
|
|
||||||
$response->headers->set('HX-Retarget', 'body');
|
|
||||||
$response->headers->set('HX-Reswap', 'beforeend');
|
|
||||||
|
|
||||||
$event->setResponse($response);
|
|
||||||
$event->allowCustomResponseCode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Security;
|
namespace App\Security;
|
||||||
|
|
||||||
|
use App\Htmx\HxRedirectResponse;
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -16,6 +17,10 @@ use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface
|
|||||||
*
|
*
|
||||||
* Deliberately the login *page* rather than straight into /auth/init: the page is where a failed
|
* Deliberately the login *page* rather than straight into /auth/init: the page is where a failed
|
||||||
* login's flash message is shown, and bouncing there instead would swallow it.
|
* login's flash message is shown, and bouncing there instead would swallow it.
|
||||||
|
*
|
||||||
|
* An htmx request -- typically a click after the session expired -- gets HX-Redirect instead:
|
||||||
|
* a plain 302 is followed invisibly by the browser, and htmx would swap the whole login page into
|
||||||
|
* whatever the click targeted, a modal included.
|
||||||
*/
|
*/
|
||||||
final readonly class LoginEntryPoint implements AuthenticationEntryPointInterface
|
final readonly class LoginEntryPoint implements AuthenticationEntryPointInterface
|
||||||
{
|
{
|
||||||
@@ -25,6 +30,12 @@ final readonly class LoginEntryPoint implements AuthenticationEntryPointInterfac
|
|||||||
|
|
||||||
public function start(Request $request, ?AuthenticationException $authException = null): Response
|
public function start(Request $request, ?AuthenticationException $authException = null): Response
|
||||||
{
|
{
|
||||||
return new RedirectResponse($this->urlGenerator->generate('app_login'));
|
$url = $this->urlGenerator->generate('app_login');
|
||||||
|
|
||||||
|
if ($request->headers->has('HX-Request')) {
|
||||||
|
return new HxRedirectResponse($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RedirectResponse($url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{#
|
||||||
|
Symfony's error page for anything but a 404, adopted from myep-team. Only used with debug off;
|
||||||
|
dev keeps showing the exception page (preview at /_error/500).
|
||||||
|
|
||||||
|
Never reached by an htmx request: a failed one reloads its page instead (see assets/app.js).
|
||||||
|
#}
|
||||||
|
{% extends 'layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Fehler · ephub{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-2xl font-bold pb-8">
|
||||||
|
Oops... Ein Fehler ist aufgetreten
|
||||||
|
</h1>
|
||||||
|
<p class="mb-8">
|
||||||
|
Wir haben uns ein paar Notizen dazu gemacht und werden das Problem so schnell wie möglich beseitigen.
|
||||||
|
</p>
|
||||||
|
<a class="button button--primary" href="{{ path('app_dashboard') }}">
|
||||||
|
nochmal von vorne
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{# Symfony's 404 page, adopted from myep-team. Only used with debug off. #}
|
||||||
|
{% extends 'layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Nicht gefunden · ephub{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-2xl font-bold pb-8">
|
||||||
|
Oops... Ein Fehler ist aufgetreten
|
||||||
|
</h1>
|
||||||
|
<p class="mb-8">
|
||||||
|
Die von dir aufgerufene Seite können wir leider nicht (mehr) finden.
|
||||||
|
</p>
|
||||||
|
<a class="button button--primary" href="{{ path('app_dashboard') }}">
|
||||||
|
nochmal von vorne
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{% embed 'htmx_modal.html.twig' %}
|
|
||||||
{% block title %}Oops…{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="bg-red-600 text-gray-100 rounded px-4 py-3 text-sm">
|
|
||||||
Da ist etwas schiefgelaufen. Wir haben uns Notizen gemacht und kümmern uns
|
|
||||||
schnellstmöglich darum.
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
{% endembed %}
|
|
||||||
@@ -43,8 +43,7 @@ class ResourceAdminControllerTest extends WebTestCase
|
|||||||
$this->client->request('GET', '/admin/resources');
|
$this->client->request('GET', '/admin/resources');
|
||||||
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||||
|
|
||||||
// Not as htmx: an htmx request gets the error modal instead of the bare status.
|
$this->client->request('GET', '/admin/resources/new/desk', server: ['HTTP_HX-Request' => 'true']);
|
||||||
$this->client->request('GET', '/admin/resources/new/desk');
|
|
||||||
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +122,7 @@ class ResourceAdminControllerTest extends WebTestCase
|
|||||||
{
|
{
|
||||||
$this->client->loginUser($this->admin());
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
$this->client->request('GET', '/admin/resources/new/boat');
|
$this->client->request('GET', '/admin/resources/new/boat', server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
|
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
@@ -221,7 +220,7 @@ class ResourceAdminControllerTest extends WebTestCase
|
|||||||
$crawler = $this->client->request('GET', '/admin/resources');
|
$crawler = $this->client->request('GET', '/admin/resources');
|
||||||
self::assertCount(0, $crawler->filter(\sprintf('[hx-get="/admin/resource-groups/%d/delete"]', $room->getId())));
|
self::assertCount(0, $crawler->filter(\sprintf('[hx-get="/admin/resource-groups/%d/delete"]', $room->getId())));
|
||||||
|
|
||||||
$this->client->request('POST', \sprintf('/admin/resource-groups/%d/delete', $room->getId()), ['_token' => 'whatever']);
|
$this->client->request('POST', \sprintf('/admin/resource-groups/%d/delete', $room->getId()), ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']);
|
||||||
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,12 +155,6 @@ class BookingControllerTest extends WebTestCase
|
|||||||
self::assertNull($this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]));
|
self::assertNull($this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Note the status is 200, not 403: HtmxExceptionListener deliberately converts every
|
|
||||||
* exception on an htmx request -- access denied included -- into a visible error modal,
|
|
||||||
* because htmx discards a 4xx body and the click would otherwise do nothing at all.
|
|
||||||
* What matters here is that the booking survives.
|
|
||||||
*/
|
|
||||||
public function testReleasingAnotherPersonsBookingIsRefused(): void
|
public function testReleasingAnotherPersonsBookingIsRefused(): void
|
||||||
{
|
{
|
||||||
$booking = new Booking($this->user('[email protected]'), $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
|
$booking = new Booking($this->user('[email protected]'), $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
|
||||||
@@ -170,8 +164,7 @@ class BookingControllerTest extends WebTestCase
|
|||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
$this->client->request('POST', \sprintf('/bookings/%d/release', $booking->getId()), server: ['HTTP_HX-Request' => 'true']);
|
$this->client->request('POST', \sprintf('/bookings/%d/release', $booking->getId()), server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
|
||||||
$response = $this->client->getResponse();
|
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||||
self::assertSame('body', $response->headers->get('HX-Retarget'));
|
|
||||||
self::assertNotNull($this->em->getRepository(Booking::class)->find($booking->getId()));
|
self::assertNotNull($this->em->getRepository(Booking::class)->find($booking->getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\EventListener;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failed htmx request keeps its status, and the page reload that app.js answers it with shows
|
||||||
|
* why it happened.
|
||||||
|
*/
|
||||||
|
class HtmxErrorFlashListenerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private const MESSAGE = 'Da ist etwas schiefgelaufen.';
|
||||||
|
|
||||||
|
private KernelBrowser $client;
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->client = self::createClient();
|
||||||
|
$this->client->disableReboot();
|
||||||
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$this->em->getConnection()->beginTransaction();
|
||||||
|
|
||||||
|
$user = new User('[email protected]', 'Anna');
|
||||||
|
$user->setRoles(['ROLE_EMPLOYEE']);
|
||||||
|
$this->em->persist($user);
|
||||||
|
$this->em->flush();
|
||||||
|
$this->client->loginUser($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->em->getConnection()->rollBack();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAFailedHtmxRequestKeepsItsStatusAndLeavesAFlash(): void
|
||||||
|
{
|
||||||
|
$this->client->request('GET', '/admin/resources', server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||||
|
self::assertFalse($this->client->getResponse()->headers->has('HX-Retarget'));
|
||||||
|
|
||||||
|
$this->client->request('GET', '/');
|
||||||
|
self::assertSelectorTextContains('#flashes', self::MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An ordinary page request shows Symfony's error page instead, so a flash would only turn up
|
||||||
|
* later, out of context.
|
||||||
|
*/
|
||||||
|
public function testAnOrdinaryRequestLeavesNoFlash(): void
|
||||||
|
{
|
||||||
|
$this->client->request('GET', '/admin/resources');
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||||
|
|
||||||
|
$this->client->request('GET', '/');
|
||||||
|
self::assertSelectorTextNotContains('#flashes', self::MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\EventListener;
|
|
||||||
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drives the listener through the real kernel, which is the only way to prove
|
|
||||||
* allowCustomResponseCode() actually holds the 200 -- the kernel rewrites it otherwise, and a
|
|
||||||
* unit test on the event object cannot see that happen.
|
|
||||||
*
|
|
||||||
* An unrouted path is used as the exception source so no throwing fixture controller is needed.
|
|
||||||
*/
|
|
||||||
class HtmxExceptionListenerFunctionalTest extends WebTestCase
|
|
||||||
{
|
|
||||||
public function testAnExceptionOnAnHtmxRequestArrivesAsA200ErrorModal(): void
|
|
||||||
{
|
|
||||||
$client = self::createClient();
|
|
||||||
$client->catchExceptions(true);
|
|
||||||
$client->request('GET', '/does-not-exist', server: ['HTTP_HX-Request' => 'true']);
|
|
||||||
|
|
||||||
$response = $client->getResponse();
|
|
||||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
|
||||||
self::assertSame('body', $response->headers->get('HX-Retarget'));
|
|
||||||
self::assertSame('beforeend', $response->headers->get('HX-Reswap'));
|
|
||||||
self::assertStringContainsString('id="htmx-modal"', (string) $response->getContent());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testTheSameExceptionOnAnOrdinaryRequestStillGivesAnHonestErrorStatus(): void
|
|
||||||
{
|
|
||||||
$client = self::createClient();
|
|
||||||
$client->catchExceptions(true);
|
|
||||||
$client->request('GET', '/does-not-exist');
|
|
||||||
|
|
||||||
// Asserting on headers rather than body: in debug mode Symfony's exception page renders
|
|
||||||
// the source of every stack frame, this file included, so any marker string searched for
|
|
||||||
// here would match its own source code.
|
|
||||||
self::assertSame(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode());
|
|
||||||
self::assertFalse($client->getResponse()->headers->has('HX-Retarget'));
|
|
||||||
self::assertFalse($client->getResponse()->headers->has('HX-Reswap'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\EventListener;
|
|
||||||
|
|
||||||
use App\EventListener\HtmxExceptionListener;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
|
||||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
|
||||||
use Twig\Environment;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uses the real Twig so the error modal template is exercised too -- an unrenderable modal here
|
|
||||||
* would throw from inside exception handling, which is the worst possible place for it.
|
|
||||||
*/
|
|
||||||
class HtmxExceptionListenerTest extends KernelTestCase
|
|
||||||
{
|
|
||||||
public function testHtmxRequestsGetTheErrorModalAsA200(): void
|
|
||||||
{
|
|
||||||
$event = $this->dispatch(['HTTP_HX-Request' => 'true']);
|
|
||||||
|
|
||||||
$response = $event->getResponse();
|
|
||||||
self::assertInstanceOf(Response::class, $response);
|
|
||||||
// 200, not 500: htmx does not swap 5xx, so an error response would be discarded unseen.
|
|
||||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
|
||||||
// Without this the kernel turns the 200 back into a 500.
|
|
||||||
self::assertTrue($event->isAllowingCustomResponseCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testTheModalIsRetargetedAtTheBodySoItAppearsWhateverTheTriggerWas(): void
|
|
||||||
{
|
|
||||||
$response = $this->dispatch(['HTTP_HX-Request' => 'true'])->getResponse();
|
|
||||||
|
|
||||||
self::assertNotNull($response);
|
|
||||||
self::assertSame('body', $response->headers->get('HX-Retarget'));
|
|
||||||
self::assertSame('beforeend', $response->headers->get('HX-Reswap'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testTheRenderedModalIsTheStandaloneModalFragment(): void
|
|
||||||
{
|
|
||||||
$content = (string) $this->dispatch(['HTTP_HX-Request' => 'true'])->getResponse()?->getContent();
|
|
||||||
|
|
||||||
self::assertStringContainsString('id="htmx-modal"', $content);
|
|
||||||
self::assertStringContainsString('data-controller="modal"', $content);
|
|
||||||
self::assertStringNotContainsString('<html', $content);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testOrdinaryRequestsAreLeftToSymfonysErrorHandling(): void
|
|
||||||
{
|
|
||||||
self::assertNull($this->dispatch()->getResponse());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testAResponseSetByAnEarlierListenerIsNotOverwritten(): void
|
|
||||||
{
|
|
||||||
$event = $this->event(['HTTP_HX-Request' => 'true']);
|
|
||||||
$event->setResponse($alreadySet = new Response('handled elsewhere'));
|
|
||||||
|
|
||||||
($this->listener())($event);
|
|
||||||
|
|
||||||
self::assertSame($alreadySet, $event->getResponse());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, string> $server
|
|
||||||
*/
|
|
||||||
private function dispatch(array $server = []): ExceptionEvent
|
|
||||||
{
|
|
||||||
$event = $this->event($server);
|
|
||||||
($this->listener())($event);
|
|
||||||
|
|
||||||
return $event;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, string> $server
|
|
||||||
*/
|
|
||||||
private function event(array $server): ExceptionEvent
|
|
||||||
{
|
|
||||||
return new ExceptionEvent(
|
|
||||||
self::bootKernel(),
|
|
||||||
Request::create('/bookings', server: $server),
|
|
||||||
HttpKernelInterface::MAIN_REQUEST,
|
|
||||||
new \RuntimeException('boom'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function listener(): HtmxExceptionListener
|
|
||||||
{
|
|
||||||
return new HtmxExceptionListener(self::getContainer()->get(Environment::class));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Security;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
class LoginEntryPointTest extends WebTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* A plain 302 would be followed invisibly and the login page swapped into the click's target.
|
||||||
|
*
|
||||||
|
* The status stays 403 -- Symfony's firewall keeps the access-denied status on this path --
|
||||||
|
* and that is fine: htmx 2.0.10 acts on HX-Redirect before it looks at the status at all
|
||||||
|
* (handleAjaxResponse), so it navigates rather than raising htmx:responseError.
|
||||||
|
*/
|
||||||
|
public function testAnExpiredSessionOnAnHtmxRequestNavigatesToTheLoginPage(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
|
||||||
|
$client->request('GET', '/book/desk', server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
|
||||||
|
self::assertSame('/login', $client->getResponse()->headers->get('HX-Redirect'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnExpiredSessionOnAnOrdinaryRequestStillRedirects(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
|
||||||
|
$client->request('GET', '/book/desk');
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user