client = self::createClient(server: ['HTTPS' => true]);
$this->client->disableReboot();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->em->getConnection()->beginTransaction();
// Every day these tests book on is counted from here. Fixed dates fell into the past and
// started being refused; the next Monday is always ahead, always inside the booking
// horizon, and keeps the weekday the series tests count on.
$this->monday = new \DateTimeImmutable('next monday');
}
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/'.$this->day(), server: ['HTTP_HX-Request' => 'true']);
$response = $this->client->getResponse();
self::assertResponseIsSuccessful();
self::assertSame('/book/desk?date='.$this->day(), $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/'.$this->day());
self::assertResponseRedirects('/book/desk?date='.$this->day());
}
/**
* 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, $this->day());
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'), $this->day(), ['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($this->day())));
$this->em->flush();
$this->client->loginUser($this->user('anna@ep-reisen.de'));
$response = $this->submitBookingModal($desk, $this->day());
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
self::assertStringContainsString(\sprintf('ist am %s bereits belegt', $this->day(0, 'd.m.Y')), (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($this->day())));
$this->em->flush();
$this->client->loginUser($anna);
$response = $this->submitBookingModal($this->resource('D2'), $this->day());
$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($this->day())));
$this->em->persist(new Booking($anna, $this->resource('D2'), new \DateTimeImmutable($this->day())));
$this->em->flush();
$this->client->loginUser($anna);
$response = $this->submitBookingModal($this->resource('D3'), $this->day());
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($this->day()), TimeRange::fromMinutes(540, 720)));
$this->em->flush();
$this->client->loginUser($anna);
$response = $this->submitBookingModal($this->resource('D2'), $this->day(), [
'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($this->day())));
$this->em->flush();
$this->client->loginUser($anna);
$response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), $this->day());
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($this->day()), TimeRange::fromMinutes(600, 660)));
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable($this->day(14)), TimeRange::fromMinutes(600, 660)));
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable($this->day(15)), TimeRange::fromMinutes(600, 660)));
$this->em->flush();
$this->client->loginUser($anna);
$response = $this->submitBookingModal($this->resource('D1'), $this->day(), [
'startTime' => '09:00',
'endTime' => '12:00',
'repeat' => 'weekly',
'until' => $this->day(25),
]);
$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(\sprintf('(%s, %s)', $this->day(0, 'j.n.'), $this->day(14, 'j.n.')), $content);
}
public function testMovingABookingOntoAnotherOneWarns(): void
{
$anna = $this->user('anna@ep-reisen.de');
$mine = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(780, 1020));
$this->em->persist($mine);
$this->em->persist(new Booking($anna, $this->resource('D2'), new \DateTimeImmutable($this->day()), 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/%s', $desk->getId(), $this->day()), [
'booking' => ['date' => $this->day(), '_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($this->day()));
$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($this->day()));
$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($this->day()));
$this->em->persist($booking);
$this->em->flush();
$this->client->loginUser($anna);
$crawler = $this->client->request('GET', '/book/desk?date='.$this->day());
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($this->day()), 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='.$this->day());
$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, $this->day(), [
'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($this->day())));
$this->em->flush();
$this->client->loginUser($this->user('anna@ep-reisen.de'));
$crawler = $this->client->request('GET', '/book/desk?date='.$this->day());
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, $this->day(), [
'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, $this->day());
$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'), $this->day(), [
'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($this->day()), TimeRange::fromMinutes(540, 780)));
$this->em->flush();
$this->client->loginUser($this->user('anna@ep-reisen.de'));
$response = $this->submitBookingModal($desk, $this->day(), [
'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');
// day() is a Monday, and the weekday is taken from it rather than asked for -- the
// Mondays up to the Friday three weeks on are four.
$response = $this->submitBookingModal($desk, $this->day(), [
'startTime' => '09:00',
'endTime' => '12:00',
'repeat' => 'weekly',
'until' => $this->day(25),
]);
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');
// 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'), $this->day(), [
'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'), $this->day(), [
'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());
}
/**
* The horizon used to be lifted for ROLE_ADMIN on the grounds that an admin books for other
* people. They do not -- this modal always books for whoever is using it, and the admin area
* is read-only -- so the exemption only let an admin block a desk for themselves half a year
* ahead.
*/
public function testAnAdminIsBoundByTheHorizonToo(): 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_UNPROCESSABLE_ENTITY, $response->getStatusCode());
self::assertStringContainsString('höchstens 3 Monate im Voraus', (string) $response->getContent());
}
/**
* 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'));
// Mid-month of last month rather than yesterday: on the 1st, yesterday is a spill-over
// day from the month before, and the out-of-month styling takes precedence over "past".
$selected = (new \DateTimeImmutable('first day of last month midnight'))->modify('+14 days');
$past = $selected->modify('-1 day')->format('Y-m-d');
$crawler = $this->client->request('GET', '/book/desk?date='.$selected->format('Y-m-d'));
$cell = $crawler->filter(\sprintf('button[hx-get*="/book/desk/day/%s"]', $past));
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'));
// Mid-month of next month rather than tomorrow: on a month's last day, tomorrow falls
// outside the grid whenever that day is a Sunday.
$selected = (new \DateTimeImmutable('first day of next month midnight'))->modify('+14 days');
$future = $selected->modify('+1 day')->format('Y-m-d');
$crawler = $this->client->request('GET', '/book/desk?date='.$selected->format('Y-m-d'));
$cell = $crawler->filter(\sprintf('button[hx-get*="/book/desk/day/%s"]', $future));
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/%s?from=09:00&to=13:00', $desk->getId(), $this->day()),
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/'.$this->day().'?from=09:00&to=13:00', server: ['HTTP_HX-Request' => 'true']);
self::assertResponseIsSuccessful();
self::assertSame('/book/desk?date='.$this->day().'&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, $this->day(), [
'startTime' => '09:00',
'endTime' => '12:00',
'repeat' => 'weekly',
'until' => $this->day(21),
]);
$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();
}
/**
* A day counted from the next Monday, as the routes and form fields spell it.
*/
private function day(int $offset = 0, string $format = 'Y-m-d'): string
{
return $this->monday->modify(\sprintf('+%d days', $offset))->format($format);
}
/**
* 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;
}
}