505 lines
21 KiB
PHP
505 lines
21 KiB
PHP
<?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;
|
||
}
|
||
}
|