Files
ephub/tests/Controller/BookingControllerTest.php
T

611 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Config\BookingHorizon;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\User;
use App\Enum\ResourceType;
use App\Model\TimeRange;
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(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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[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');
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');
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');
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('/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('[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('/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('[email protected]');
$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('[email protected]');
$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('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780)));
$this->em->flush();
$this->client->loginUser($this->user('[email protected]'));
// 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 -- <input type="time"> 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('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01')));
$this->em->flush();
$this->client->loginUser($this->user('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780)));
$this->em->flush();
$this->client->loginUser($this->user('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]'));
$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('[email protected]');
$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<string, mixed> $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;
}
}