feat: add the booking board with floor plan and calendar
This commit is contained in:
1 parent
f4741e1bee
commit
59feca2f8e
22 files changed
+1015
-17
No files matched your search
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Drives the htmx booking flow through the real kernel.
|
||||
*
|
||||
* Rebooting is disabled so the whole test stays on one connection and the surrounding
|
||||
* transaction can be rolled back -- with the default reboot-per-request the connection would be
|
||||
* recreated and the rollback would take nothing with it.
|
||||
*/
|
||||
class BookingControllerTest extends WebTestCase
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->em->getConnection()->rollBack();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testPickingADayReturnsNothingButOutOfBandBlocks(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$this->client->request('GET', '/tag/2026-10-01', server: ['HTTP_HX-Request' => 'true']);
|
||||
$response = $this->client->getResponse();
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame('/?tag=2026-10-01', $response->headers->get('HX-Push-Url'));
|
||||
self::assertSame(2, substr_count((string) $response->getContent(), 'hx-swap-oob="true"'));
|
||||
self::assertStringContainsString('id="board-calendar"', (string) $response->getContent());
|
||||
self::assertStringContainsString('id="board-floorplan"', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testTheDayRouteRedirectsWhenItIsNotAnHtmxRequest(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$this->client->request('GET', '/tag/2026-10-01');
|
||||
|
||||
self::assertResponseRedirects('/?tag=2026-10-01');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification step 6 of the plan: the CSRF token is an ordinary session-backed form field,
|
||||
* so an htmx submit carrying it passes without any header bridge.
|
||||
*/
|
||||
public function testBookingAFreeDeskPassesCsrfAndSwapsTheBoard(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01', '2026-10-01');
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
// The modal is gone from the response, which is what closes it: an out-of-band-only
|
||||
// body leaves the outerHTML target with nothing to swap in.
|
||||
self::assertStringNotContainsString('id="htmx-modal"', (string) $response->getContent());
|
||||
self::assertSame(3, substr_count((string) $response->getContent(), 'hx-swap-oob="true"'));
|
||||
self::assertNotNull($this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification step 7: this is the path that silently does nothing if the 422 entry is
|
||||
* missing from htmx's responseHandling.
|
||||
*/
|
||||
public function testAnInvalidSubmitRerendersTheModalWith422(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', '2020-01-01');
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('id="htmx-modal"', (string) $response->getContent());
|
||||
self::assertStringContainsString('Für vergangene Tage kann nicht gebucht werden.', (string) $response->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification step 8: one success and one handled inline error, not a 500.
|
||||
*/
|
||||
public function testADeskAlreadyTakenIsReportedInsideTheModal(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01')));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01', '2026-10-01');
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('ist am 01.10.2026 bereits belegt', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testOnePersonCannotHoldTwoDesksOnTheSameDay(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D2'), '2026-10-01', '2026-10-01');
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('bereits D1 gebucht', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testADeskAndAParkingSpotOnTheSameDayAreBothAllowed(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), '2026-10-01', '2026-10-01');
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testAnInvalidCsrfTokenIsRejected(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$this->client->request('POST', \sprintf('/buchungen/neu/%d/2026-10-01', $desk->getId()), [
|
||||
'booking' => ['date' => '2026-10-01', '_token' => 'nope'],
|
||||
], server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $this->client->getResponse()->getStatusCode());
|
||||
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'));
|
||||
$this->em->persist($booking);
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$this->client->request('POST', \sprintf('/buchungen/%d/freigeben', $booking->getId()), server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
$response = $this->client->getResponse();
|
||||
self::assertSame('body', $response->headers->get('HX-Retarget'));
|
||||
self::assertNotNull($this->em->getRepository(Booking::class)->find($booking->getId()));
|
||||
}
|
||||
|
||||
public function testReleasingOwnBookingSwapsTheBoard(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$booking = new Booking($anna, $this->resource('D1', svgElementId: 'desk-01'), new \DateTimeImmutable('2026-10-01'));
|
||||
$this->em->persist($booking);
|
||||
$this->em->flush();
|
||||
$id = $booking->getId();
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
|
||||
// Read the token off the rendered plan rather than minting one: outside a request there
|
||||
// is no session for the token storage to live in, and this is the real round trip anyway.
|
||||
$crawler = $this->client->request('GET', '/?tag=2026-10-01');
|
||||
$vals = $crawler->filter(\sprintf('[hx-post="/buchungen/%d/freigeben"]', $id))->attr('hx-vals');
|
||||
$token = json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
|
||||
|
||||
$this->client->request('POST', \sprintf('/buchungen/%d/freigeben', $id), ['_token' => $token], server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame(3, substr_count((string) $this->client->getResponse()->getContent(), 'hx-swap-oob="true"'));
|
||||
self::assertNull($this->em->getRepository(Booking::class)->find($id));
|
||||
}
|
||||
|
||||
private function submitBookingModal(Resource $resource, string $routeDate, string $submittedDate): Response
|
||||
{
|
||||
$crawler = $this->client->request(
|
||||
'GET',
|
||||
\sprintf('/buchungen/neu/%d/%s', $resource->getId(), $routeDate),
|
||||
server: ['HTTP_HX-Request' => 'true'],
|
||||
);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$form = $crawler->selectButton('Buchen')->form(['booking[date]' => $submittedDate]);
|
||||
$this->client->submit($form, serverParameters: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
return $this->client->getResponse();
|
||||
}
|
||||
|
||||
private function user(string $email, string $name = 'Test'): User
|
||||
{
|
||||
$user = new User($email, $name);
|
||||
$user->setRoles(['ROLE_EMPLOYEE']);
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Note $svgElementId: the floor plan only draws resources whose id appears in the geometry
|
||||
* map in _floorplan.html.twig, so a test that needs to click a shape must use a real one.
|
||||
*/
|
||||
private function resource(string $label, ResourceType $type = ResourceType::DESK, ?string $svgElementId = null): Resource
|
||||
{
|
||||
$resource = new Resource($type, $label, $svgElementId);
|
||||
$this->em->persist($resource);
|
||||
$this->em->flush();
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user