client = self::createClient(server: ['HTTPS' => true]); $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', '/book/desk/day/2026-10-01', server: ['HTTP_HX-Request' => 'true']); $response = $this->client->getResponse(); self::assertResponseIsSuccessful(); self::assertSame('/book/desk?date=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', '/book/desk/day/2026-10-01'); self::assertResponseRedirects('/book/desk?date=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'); 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', ['allDay' => '1', 'date' => '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'); self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); self::assertStringContainsString('ist am 01.10.2026 bereits belegt', (string) $response->getContent()); } /** * A second desk at the same time used to be refused. It is allowed now -- the second one is * usually for the customer somebody is meeting -- and the flash is what tells them they are * now holding two. */ public function testASecondDeskAtTheSameTimeIsBookedAndWarnedAbout(): 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'); $content = (string) $response->getContent(); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertStringContainsString('D2 gebucht', $content, 'the booking itself still succeeds'); self::assertStringContainsString('zur gleichen Zeit bereits D1 gebucht', $content); } public function testEveryClashingDeskIsNamedInTheWarning(): void { $anna = $this->user('anna@ep-reisen.de'); $this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'))); $this->em->persist(new Booking($anna, $this->resource('D2'), new \DateTimeImmutable('2026-10-01'))); $this->em->flush(); $this->client->loginUser($anna); $response = $this->submitBookingModal($this->resource('D3'), '2026-10-01'); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertStringContainsString('bereits 2 weitere Schreibtische gebucht: D1, D2', (string) $response->getContent()); } public function testTwoDesksAtDifferentTimesAreNotWarnedAbout(): void { $anna = $this->user('anna@ep-reisen.de'); $this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 720))); $this->em->flush(); $this->client->loginUser($anna); $response = $this->submitBookingModal($this->resource('D2'), '2026-10-01', [ 'startTime' => '13:00', 'endTime' => '17:00', ]); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertStringNotContainsString('Hinweis', (string) $response->getContent()); } /** * The deliberately quiet case: a desk and a parking space at the same time is the everyday * combination, and a toast for it would train everybody to ignore the toast that matters. */ public function testADeskAndAParkingSpotOnTheSameDayAreBothAllowedWithoutAWarning(): 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'); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertStringNotContainsString('Hinweis', (string) $response->getContent()); } public function testASeriesIsWarnedAboutOnceForAllItsClashingDays(): void { $anna = $this->user('anna@ep-reisen.de'); $other = $this->resource('D2'); // Two of the four Mondays the series lands on, and one Tuesday it does not. $this->em->persist(new Booking($anna, $other, new \DateTimeImmutable('2026-10-05'), TimeRange::fromMinutes(600, 660))); $this->em->persist(new Booking($anna, $other, new \DateTimeImmutable('2026-10-19'), TimeRange::fromMinutes(600, 660))); $this->em->persist(new Booking($anna, $other, new \DateTimeImmutable('2026-10-20'), TimeRange::fromMinutes(600, 660))); $this->em->flush(); $this->client->loginUser($anna); $response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [ 'startTime' => '09:00', 'endTime' => '12:00', 'repeat' => 'weekly', 'until' => '2026-10-30', ]); $content = (string) $response->getContent(); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame(1, substr_count($content, 'Hinweis:'), 'one flash for the whole series'); self::assertStringContainsString('An 2 von 4 Terminen', $content); self::assertStringContainsString('(5.10., 19.10.)', $content); } public function testMovingABookingOntoAnotherOneWarns(): void { $anna = $this->user('anna@ep-reisen.de'); $mine = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(780, 1020)); $this->em->persist($mine); $this->em->persist(new Booking($anna, $this->resource('D2'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 720))); $this->em->flush(); $this->client->loginUser($anna); $url = \sprintf('/bookings/%d/edit', $mine->getId()); $crawler = $this->client->request('GET', $url, server: ['HTTP_HX-Request' => 'true']); $this->client->request('POST', $url, ['booking' => [ 'startTime' => '10:00', 'endTime' => '11:00', '_token' => $crawler->filter('#booking__token')->attr('value'), ]], server: ['HTTP_HX-Request' => 'true']); $response = $this->client->getResponse(); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertStringContainsString('zur gleichen Zeit bereits D2 gebucht', (string) $response->getContent()); } public function testAnInvalidCsrfTokenIsRejected(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $desk = $this->resource('D1'); $this->client->request('POST', \sprintf('/bookings/new/%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])); } 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('/bookings/%d/release', $booking->getId()), server: ['HTTP_HX-Request' => 'true']); self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); 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'), new \DateTimeImmutable('2026-10-01')); $this->em->persist($booking); $this->em->flush(); $id = $booking->getId(); $this->client->loginUser($anna); // The whole two-step flow: the edit modal opens the confirmation modal, and the // confirmation modal is where the token and the POST live. $crawler = $this->client->request('GET', \sprintf('/bookings/%d/edit', $id), server: ['HTTP_HX-Request' => 'true']); self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/release"]', $id))); $token = $this->confirmationToken(\sprintf('/bookings/%d/release', $id)); $this->client->request('POST', \sprintf('/bookings/%d/release', $id), ['_token' => $token], server: ['HTTP_HX-Request' => 'true']); $content = (string) $this->client->getResponse()->getContent(); self::assertResponseIsSuccessful(); self::assertSame(3, substr_count($content, 'hx-swap-oob="true"')); // The confirmation swaps itself away; the edit modal underneath it has to go too. self::assertStringContainsString('id="htmx-modal" hx-swap-oob="delete"', $content); self::assertNull($this->em->getRepository(Booking::class)->find($id)); } public function testTheFloorPlanOpensTheEditModalForYourOwnBooking(): void { $anna = $this->user('anna@ep-reisen.de'); $booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')); $this->em->persist($booking); $this->em->flush(); $this->client->loginUser($anna); $crawler = $this->client->request('GET', '/book/desk?date=2026-10-01'); self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/edit"]', $booking->getId()))); } /** * The reported bug, end to end: one person holds 09:00-13:00, a second opens the board on * its default all-day span, and must be able to take the rest of the day. * * The plan used to render that desk exactly like a fully booked one and give it no hx-get at * all, so the second person could not reach the modal unless they happened to narrow the * span by hand first. */ public function testASecondPersonCanBookTheRestOfADayFromTheDefaultBoard(): void { $desk = $this->resource('D1'); $this->em->persist(new Booking($this->user('bea@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780))); $this->em->flush(); $this->client->loginUser($this->user('anna@ep-reisen.de')); // 1. The shape is clickable on the all-day board. $crawler = $this->client->request('GET', '/book/desk?date=2026-10-01'); $opener = $crawler->filter(\sprintf('[data-spot][hx-get*="/bookings/new/%d/"]', $desk->getId())); self::assertCount(1, $opener, 'a partly booked desk must still open the booking modal'); // 2. The modal it opens is prefilled with a window that is actually free. $modal = $this->client->request('GET', $opener->attr('hx-get'), server: ['HTTP_HX-Request' => 'true']); self::assertSame('13:00', $modal->filter('#booking_startTime')->attr('value')); // Blank, not 24:00 -- cannot hold it, and blank already means // "to the end of the day" here just as it does on the board's span picker. self::assertNull($modal->filter('#booking_endTime')->attr('value')); // 3. Submitting it works. $response = $this->submitBookingModal($desk, '2026-10-01', [ 'startTime' => '13:00', 'endTime' => '17:00', ]); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertCount(2, $this->em->getRepository(Booking::class)->findBy(['resource' => $desk])); } public function testAFullyBookedDeskIsNotClickable(): 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')); $crawler = $this->client->request('GET', '/book/desk?date=2026-10-01'); self::assertCount(0, $crawler->filter(\sprintf('[data-spot][hx-get*="/bookings/new/%d/"]', $desk->getId()))); } public function testBookingPartOfADay(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $desk = $this->resource('D1'); $response = $this->submitBookingModal($desk, '2026-10-01', [ 'startTime' => '09:00', 'endTime' => '13:00', ]); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); $booking = $this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]); self::assertNotNull($booking); self::assertSame('09:00–13:00', (string) $booking->getTimeRange()); } public function testABookingDefaultsToTheWholeDay(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $desk = $this->resource('D1'); $this->submitBookingModal($desk, '2026-10-01'); $booking = $this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]); self::assertNotNull($booking); self::assertTrue($booking->getTimeRange()->isAllDay()); } public function testAnEndBeforeTheStartIsRejected(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', [ 'startTime' => '13:00', 'endTime' => '09:00', ]); self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); self::assertStringContainsString('Das Ende muss nach dem Beginn liegen.', (string) $response->getContent()); } public function testAPartDayBookingLeavesTheRestOfTheDayBookable(): void { $desk = $this->resource('D1'); $this->em->persist(new Booking($this->user('bea@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780))); $this->em->flush(); $this->client->loginUser($this->user('anna@ep-reisen.de')); $response = $this->submitBookingModal($desk, '2026-10-01', [ 'startTime' => '13:00', 'endTime' => '17:00', ]); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); } public function testAWeeklySeriesIsCreatedAndReported(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $desk = $this->resource('D1'); // 2026-10-05 is a Monday, and the weekday is taken from it rather than asked for -- // the Mondays up to the 30th are the 5th, 12th, 19th and 26th. $response = $this->submitBookingModal($desk, '2026-10-05', [ 'startTime' => '09:00', 'endTime' => '12:00', 'repeat' => 'weekly', 'until' => '2026-10-30', ]); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); $occurrences = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk]); self::assertCount(4, $occurrences); self::assertStringContainsString('4 Termine gebucht', (string) $response->getContent()); foreach ($occurrences as $booking) { self::assertSame(1, (int) $booking->getDate()->format('N')); } } public function testAFourWeeklySeriesIsCreatedAndReported(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $desk = $this->resource('D1'); // Relative to today, unlike the rest of this file: the three-month horizon moves with the // clock, so a fixed end date here would start failing the day it fell outside it. $start = (new \DateTimeImmutable('today'))->modify('next monday'); $until = $start->modify('+56 days'); $response = $this->submitBookingModal($desk, $start->format('Y-m-d'), [ 'allDay' => '1', 'repeat' => 'four_weekly', 'until' => $until->format('Y-m-d'), ]); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); $occurrences = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk]); self::assertCount(3, $occurrences); foreach ($occurrences as $booking) { self::assertSame(1, (int) $booking->getDate()->format('N')); } } public function testASeriesWithoutAnEndDateIsRejected(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [ 'allDay' => '1', 'repeat' => 'daily', ]); self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); self::assertStringContainsString('Bitte angeben, bis wann wiederholt wird.', (string) $response->getContent()); } public function testASeriesRunningTooFarOutIsRejected(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [ 'allDay' => '1', 'repeat' => 'daily', 'until' => '2030-01-01', ]); self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); self::assertStringContainsString('höchstens 3 Monate', (string) $response->getContent()); } public function testABookingBeyondTheHorizonIsRejected(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $beyond = BookingHorizon::latest()->modify('+1 day'); $response = $this->submitBookingModal($this->resource('D1'), $beyond->format('Y-m-d'), ['allDay' => '1']); self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); self::assertStringContainsString('höchstens 3 Monate im Voraus', (string) $response->getContent()); } public function testTheLastDayInsideTheHorizonIsStillBookable(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $response = $this->submitBookingModal($this->resource('D1'), BookingHorizon::latest()->format('Y-m-d'), ['allDay' => '1']); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); } /** * The hole in the old rule, which measured the cap from the series' own start date: starting * later simply pushed the horizon out with it. This is the property that would regress * silently if the measurement ever moved back onto $this->date. */ public function testASeriesCannotPushTheHorizonOutByStartingLater(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $start = (new \DateTimeImmutable('today'))->modify('+2 months'); $response = $this->submitBookingModal($this->resource('D1'), $start->format('Y-m-d'), [ 'allDay' => '1', 'repeat' => 'weekly', 'until' => $start->modify('+2 months')->format('Y-m-d'), ]); self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); self::assertStringContainsString('höchstens 3 Monate in die Zukunft', (string) $response->getContent()); } public function testAnAdminIsNotBoundByTheHorizon(): void { $this->client->loginUser($this->admin('chef@ep-reisen.de')); $beyond = BookingHorizon::latest()->modify('+2 months'); $response = $this->submitBookingModal($this->resource('D1'), $beyond->format('Y-m-d'), ['allDay' => '1']); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); } /** * Past days stay clickable -- looking back at a plan is legitimate -- but they have to say * that nothing can be booked there, which they did not before. */ public function testPastDaysInTheCalendarAreMarkedButStillNavigable(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $yesterday = (new \DateTimeImmutable('today'))->modify('-1 day')->format('Y-m-d'); $crawler = $this->client->request('GET', '/book/desk'); $cell = $crawler->filter(\sprintf('button[hx-get*="/book/desk/day/%s"]', $yesterday)); self::assertCount(1, $cell); self::assertSame('true', $cell->attr('aria-disabled')); self::assertStringContainsString('border-dashed', (string) $cell->attr('class')); } public function testFutureDaysInTheCalendarAreNotMarkedAsPast(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $tomorrow = (new \DateTimeImmutable('today'))->modify('+1 day')->format('Y-m-d'); $crawler = $this->client->request('GET', '/book/desk'); $cell = $crawler->filter(\sprintf('button[hx-get*="/book/desk/day/%s"]', $tomorrow)); self::assertCount(1, $cell); self::assertNull($cell->attr('aria-disabled')); } /** * The board's span is carried into the modal, so narrowing the board and clicking a desk * books exactly what was on screen. */ public function testTheModalIsPrefilledFromTheBoardsSpan(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $desk = $this->resource('D1'); $crawler = $this->client->request( 'GET', \sprintf('/bookings/new/%d/2026-10-01?from=09:00&to=13:00', $desk->getId()), server: ['HTTP_HX-Request' => 'true'], ); self::assertSame('09:00', $crawler->filter('#booking_startTime')->attr('value')); self::assertSame('13:00', $crawler->filter('#booking_endTime')->attr('value')); self::assertNull($crawler->filter('#booking_allDay')->attr('checked')); } public function testTheBoardNarrowsToASpan(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $this->client->request('GET', '/book/desk/day/2026-10-01?from=09:00&to=13:00', server: ['HTTP_HX-Request' => 'true']); self::assertResponseIsSuccessful(); self::assertSame('/book/desk?date=2026-10-01&from=09:00&to=13:00', $this->client->getResponse()->headers->get('HX-Push-Url')); } public function testOverridingOneOccurrenceLeavesTheSeriesAlone(): void { $anna = $this->user('anna@ep-reisen.de'); $this->client->loginUser($anna); $desk = $this->resource('D1'); $this->submitBookingModal($desk, '2026-10-05', [ 'startTime' => '09:00', 'endTime' => '12:00', 'repeat' => 'weekly', 'until' => '2026-10-26', ]); $occurrences = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk], ['date' => 'ASC']); self::assertCount(4, $occurrences); $first = $occurrences[0]; $crawler = $this->client->request('GET', \sprintf('/bookings/%d/edit', $first->getId()), server: ['HTTP_HX-Request' => 'true']); // Only the span is editable here: the day and the recurrence belong to the series. self::assertCount(0, $crawler->filter('#booking_date')); self::assertCount(0, $crawler->filter('#booking_repeat')); $form = $crawler->selectButton('Speichern')->form([ 'booking[startTime]' => '14:00', 'booking[endTime]' => '16:00', ]); $this->client->submit($form, serverParameters: ['HTTP_HX-Request' => 'true']); self::assertResponseIsSuccessful(); $this->em->clear(); $reloaded = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk], ['date' => 'ASC']); self::assertSame('14:00–16:00', (string) $reloaded[0]->getTimeRange()); self::assertTrue($reloaded[0]->isOverridden()); // The other three are untouched. foreach (\array_slice($reloaded, 1) as $untouched) { self::assertSame('09:00–12:00', (string) $untouched->getTimeRange()); self::assertFalse($untouched->isOverridden()); } } /** * Renders the modal, reads its CSRF token, and posts the given booking fields back to it. * * Posted explicitly rather than through DomCrawler's form(), which lets a test name only the * fields it cares about and leave the rest at their rendered defaults. The token still comes * off the rendered form, so the round trip is real. (This originally worked around the * weekday checkboxes, which the crawler indexed positionally; they are gone, but posting * directly is still the simpler way to drive the modal.) * * @param array $fields */ private function submitBookingModal(Resource $resource, string $routeDate, array $fields = ['allDay' => '1']): Response { $url = \sprintf('/bookings/new/%d/%s', $resource->getId(), $routeDate); $crawler = $this->client->request('GET', $url, server: ['HTTP_HX-Request' => 'true']); self::assertResponseIsSuccessful(); $fields += [ 'date' => $routeDate, '_token' => $crawler->filter('#booking__token')->attr('value'), ]; $this->client->request('POST', $url, ['booking' => $fields], server: ['HTTP_HX-Request' => 'true']); return $this->client->getResponse(); } /** * Opens a confirmation modal and reads its CSRF token off the confirm button's hx-vals -- * the same round trip a browser makes, rather than reaching into the token manager (which * has no session to read outside a request). */ private function confirmationToken(string $uri): string { $crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']); self::assertResponseIsSuccessful(); $vals = $crawler->filter('#htmx-confirmation-modal [hx-post]')->attr('hx-vals'); return json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token']; } private function admin(string $email, string $name = 'Chef'): User { $user = $this->user($email, $name); $user->setRoles(['ROLE_ADMIN']); $this->em->flush(); return $user; } 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; } private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource { $resource = new Resource($type, $label); $this->em->persist($resource); $this->em->flush(); return $resource; } }