Files
ephub/tests/Controller/BookingControllerTest.php
T

505 lines
21 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\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
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();
$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]));
}
/**
* 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('[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']);
$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('[email protected]');
$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);
// Releasing lives in the edit modal now, not on the floor plan -- clicking your own desk
// opens that modal, and the token is rendered there.
$crawler = $this->client->request('GET', \sprintf('/bookings/%d/edit', $id), server: ['HTTP_HX-Request' => 'true']);
$vals = $crawler->filter(\sprintf('[hx-post="/bookings/%d/release"]', $id))->attr('hx-vals');
$token = json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
$this->client->request('POST', \sprintf('/bookings/%d/release', $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));
}
public function testTheFloorPlanOpensTheEditModalForYourOwnBooking(): void
{
$anna = $this->user('[email protected]');
$booking = new Booking($anna, $this->resource('D1', svgElementId: 'desk-01'), 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', svgElementId: 'desk-01');
$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('g[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', svgElementId: 'desk-01');
$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('g[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');
$response = $this->submitBookingModal($desk, '2026-10-05', [
'allDay' => '1',
'repeat' => 'four_weekly',
'until' => '2026-12-31',
]);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertCount(4, $this->em->getRepository(Booking::class)->findBy(['resource' => $desk]));
}
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 ein Enddatum für die Serie angeben.', (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 12 Monate', (string) $response->getContent());
}
/**
* 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();
}
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;
}
}