feat: reload the page with a flash when an htmx request fails

This commit is contained in:
fromme committed 2026-09-17 16:48:48 +02:00
1 parent 0be3c2ccde
commit a2e4035889
14 files changed
+205 -210

No files matched your search

@@ -43,8 +43,7 @@ class ResourceAdminControllerTest extends WebTestCase
$this->client->request('GET', '/admin/resources');
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');
$this->client->request('GET', '/admin/resources/new/desk', server: ['HTTP_HX-Request' => 'true']);
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
}
@@ -123,7 +122,7 @@ class ResourceAdminControllerTest extends WebTestCase
{
$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);
}
@@ -221,7 +220,7 @@ class ResourceAdminControllerTest extends WebTestCase
$crawler = $this->client->request('GET', '/admin/resources');
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);
}
+1 -8
View File
@@ -155,12 +155,6 @@ class BookingControllerTest extends WebTestCase
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
{
$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->request('POST', \sprintf('/bookings/%d/release', $booking->getId()), server: ['HTTP_HX-Request' => 'true']);
$response = $this->client->getResponse();
self::assertSame('body', $response->headers->get('HX-Retarget'));
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
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));
}
}
+35
View File
@@ -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');
}
}