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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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