+ {% endif %}
diff --git a/templates/booking/_modal_create.html.twig b/templates/booking/_modal_create.html.twig
new file mode 100644
index 0000000..7e371fc
--- /dev/null
+++ b/templates/booking/_modal_create.html.twig
@@ -0,0 +1,40 @@
+{#
+ The booking modal. Extending htmx_modal.html.twig keeps it a standalone fragment -- it is a
+ valid response on its own, which is what the opener's hx-swap="beforeend" onto needs.
+
+ The form targets the modal itself with outerHTML, which does double duty:
+ * invalid submit -> Symfony returns 422, htmx swaps it (see responseHandling in app.js) and
+ the modal replaces itself with the errors showing;
+ * success -> the response is nothing but out-of-band board blocks, so the modal is replaced
+ with an empty fragment, i.e. it closes.
+#}
+{% extends 'htmx_modal.html.twig' %}
+
+{% block title %}{{ resource.label }} buchen{% endblock %}
+
+{% block content %}
+ {{ form_start(form, { attr: {
+ 'hx-post': form.vars.action,
+ 'hx-target': '#htmx-modal',
+ 'hx-swap': 'outerHTML',
+ } }) }}
+ {# Root-level errors, which is where a rejected booking lands -- form_end/form_rest does
+ NOT render these, so leaving this out makes the 422 path silently blank. #}
+ {{ form_errors(form) }}
+
+
Tag im Kalender wählen, dann einen freien Platz im Raumplan anklicken.
+
+
+
+ {#
+ The two blocks below are what DashboardController::day and BookingController
+ swap out of band. Each one owns a stable id and emits hx-swap-oob only when
+ rendered through htmxOobResponse -- rendered normally they are plain markup.
+ #}
+ {% block calendar %}
+
+ {% include 'dashboard/_calendar.html.twig' %}
+
+ {% endblock %}
+
+
+
+ {% block floorplan %}
+
+ {% include 'dashboard/_floorplan.html.twig' %}
+
+ {% endblock %}
+
+
{% endblock %}
diff --git a/templates/layout.html.twig b/templates/layout.html.twig
index af26912..631414f 100644
--- a/templates/layout.html.twig
+++ b/templates/layout.html.twig
@@ -39,5 +39,11 @@
- {% include '_partials/_flashes.html.twig' %}
+ {# Swapped out of band after a booking, hence the stable id and the conditional attribute. #}
+ {% block flashes %}
+
+ {% include '_partials/_flashes.html.twig' %}
+
+ {% endblock %}
{% endblock %}
diff --git a/tests/Controller/BookingControllerTest.php b/tests/Controller/BookingControllerTest.php
new file mode 100644
index 0000000..e4b4d9a
--- /dev/null
+++ b/tests/Controller/BookingControllerTest.php
@@ -0,0 +1,236 @@
+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('anna@ep-reisen.de'));
+
+ $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('anna@ep-reisen.de'));
+
+ $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('anna@ep-reisen.de'));
+ $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('anna@ep-reisen.de'));
+
+ $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('bea@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-01')));
+ $this->em->flush();
+
+ $this->client->loginUser($this->user('anna@ep-reisen.de'));
+
+ $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('anna@ep-reisen.de');
+ $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('anna@ep-reisen.de');
+ $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('anna@ep-reisen.de'));
+ $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('bea@ep-reisen.de'), $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
+ $this->em->persist($booking);
+ $this->em->flush();
+
+ $this->client->loginUser($this->user('anna@ep-reisen.de'));
+ $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('anna@ep-reisen.de');
+ $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;
+ }
+}