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