Files
ephub/tests/Controller/BookingCalendarTest.php

324 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Config\BookingHorizon;
use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\User;
use App\Enum\RecurrenceRule;
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;
/**
* The month calendar on "Meine Buchungen".
*
* 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.
*
* Dates are all derived from today rather than written out, because the page only ever shows from
* today on and the booking horizon moves with the calendar.
*/
class BookingCalendarTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $em;
private \DateTimeImmutable $today;
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();
$this->today = new \DateTimeImmutable('today');
}
protected function tearDown(): void
{
$this->em->getConnection()->rollBack();
parent::tearDown();
}
public function testTheBookingsPageRendersTheCurrentMonthAsAFullGrid(): void
{
$this->client->loginUser($this->user('[email protected]'));
$crawler = $this->client->request('GET', '/bookings');
self::assertResponseIsSuccessful();
self::assertCount(1, $crawler->filter('#booking-calendar'));
// Six weeks of seven days, always -- the grid must not change height as you page it.
self::assertCount(42, $crawler->filter('#booking-calendar [data-date]'));
self::assertCount(1, $crawler->filter(\sprintf('#booking-calendar [data-date="%s"]', $this->today->format('Y-m-d'))));
}
public function testABookingAppearsInTheCellForItsDay(): void
{
$anna = $this->user('[email protected]');
$date = $this->bookableDate();
$this->em->persist(new Booking($anna, $this->resource('D1'), $date));
$this->em->flush();
$this->client->loginUser($anna);
$crawler = $this->client->request('GET', '/bookings?month='.$date->format('Y-m'));
$cell = $crawler->filter(\sprintf('#booking-calendar [data-date="%s"]', $date->format('Y-m-d')));
self::assertStringContainsString('D1', $cell->text());
}
public function testOnlyTheUsersOwnBookingsAppearInTheCalendar(): void
{
$anna = $this->user('[email protected]');
$date = $this->bookableDate();
$this->em->persist(new Booking($anna, $this->resource('MINE'), $date));
$this->em->persist(new Booking($this->user('[email protected]'), $this->resource('THEIRS'), $date));
$this->em->flush();
$this->client->loginUser($anna);
$crawler = $this->client->request('GET', '/bookings?month='.$date->format('Y-m'));
$calendar = $crawler->filter('#booking-calendar')->text();
self::assertStringContainsString('MINE', $calendar);
self::assertStringNotContainsString('THEIRS', $calendar);
}
public function testAnOccurrenceOfASeriesAppearsLikeAnyOtherBooking(): void
{
// Guards the assumption the whole feature rests on: a series is materialised into real
// Booking rows, so the calendar needs no recurrence expansion of its own.
$anna = $this->user('[email protected]');
$date = $this->bookableDate();
$resource = $this->resource('SERIES');
$series = new BookingSeries($anna, $resource, RecurrenceRule::WEEKLY, $date, $date->modify('+14 days'), TimeRange::wholeDay());
$this->em->persist($series);
$this->em->persist(new Booking($anna, $resource, $date, null, $series));
$this->em->flush();
$this->client->loginUser($anna);
$crawler = $this->client->request('GET', '/bookings?month='.$date->format('Y-m'));
$cell = $crawler->filter(\sprintf('#booking-calendar [data-date="%s"]', $date->format('Y-m-d')));
self::assertStringContainsString('SERIES', $cell->text());
self::assertStringContainsString('wiederkehrend', $cell->filter('a')->attr('title') ?? '');
}
public function testAPartDayChipCarriesItsStartTimeAndTheFullSpanInTheTitle(): void
{
// Also pins the TimeRange accessor: Twig resolves .allDay to isAllDay(), and a chip that
// silently lost its time would still render a plausible-looking label.
$anna = $this->user('[email protected]');
$date = $this->bookableDate();
$this->em->persist(new Booking($anna, $this->resource('D9'), $date, TimeRange::fromMinutes(9 * 60, 12 * 60)));
$this->em->flush();
$this->client->loginUser($anna);
$crawler = $this->client->request('GET', '/bookings?month='.$date->format('Y-m'));
$chip = $crawler->filter(\sprintf('#booking-calendar [data-date="%s"] a', $date->format('Y-m-d')));
self::assertStringContainsString('09:00', $chip->text());
self::assertStringContainsString('D9', $chip->text());
self::assertStringContainsString('09:00-12:00', str_replace("\u{2013}", '-', (string) $chip->attr('title')));
}
public function testAnAllDayChipShowsNoTime(): void
{
$anna = $this->user('[email protected]');
$date = $this->bookableDate();
$this->em->persist(new Booking($anna, $this->resource('D8'), $date));
$this->em->flush();
$this->client->loginUser($anna);
$crawler = $this->client->request('GET', '/bookings?month='.$date->format('Y-m'));
$chip = $crawler->filter(\sprintf('#booking-calendar [data-date="%s"] a', $date->format('Y-m-d')));
self::assertSame('D8', trim($chip->text()));
}
public function testOnlyTwoChipsAreShownPerDayAndTheRestAreCounted(): void
{
$anna = $this->user('[email protected]');
$date = $this->bookableDate();
foreach (['D1', 'D2', 'D3', 'D4'] as $label) {
$this->em->persist(new Booking($anna, $this->resource($label), $date));
}
$this->em->flush();
$this->client->loginUser($anna);
$crawler = $this->client->request('GET', '/bookings?month='.$date->format('Y-m'));
$cell = $crawler->filter(\sprintf('#booking-calendar [data-date="%s"]', $date->format('Y-m-d')));
self::assertCount(2, $cell->filter('a'), 'A cell cannot hold four labels legibly.');
self::assertStringContainsString('+2 weitere', $cell->text());
}
public function testAChipLinksToTheBoardForThatDayAndKind(): void
{
$anna = $this->user('[email protected]');
$date = $this->bookableDate();
$this->em->persist(new Booking($anna, $this->resource('P1', ResourceType::PARKING), $date));
$this->em->flush();
$this->client->loginUser($anna);
$crawler = $this->client->request('GET', '/bookings?month='.$date->format('Y-m'));
$href = $crawler->filter(\sprintf('#booking-calendar [data-date="%s"] a', $date->format('Y-m-d')))->attr('href');
self::assertSame('/book/parking?date='.$date->format('Y-m-d'), $href);
}
public function testPagingTheMonthReturnsNothingButTheCalendarOutOfBand(): void
{
$this->client->loginUser($this->user('[email protected]'));
$next = $this->today->modify('first day of next month');
$this->client->request('GET', '/bookings/month/'.$next->format('Y-m'), server: ['HTTP_HX-Request' => 'true']);
$response = $this->client->getResponse();
$content = (string) $response->getContent();
self::assertResponseIsSuccessful();
self::assertStringContainsString('id="booking-calendar"', $content);
self::assertSame(1, substr_count($content, 'hx-swap-oob="true"'));
self::assertStringNotContainsString('<html', $content, 'Only the calendar block, not the page.');
self::assertStringNotContainsString('Wiederkehrende Buchungen', $content, 'The lists are not swapped.');
self::assertSame('/bookings?month='.$next->format('Y-m'), $response->headers->get('HX-Push-Url'));
}
public function testPagingTheMonthWithoutHtmxRedirectsToTheShareablePage(): void
{
$this->client->loginUser($this->user('[email protected]'));
$next = $this->today->modify('first day of next month');
$this->client->request('GET', '/bookings/month/'.$next->format('Y-m'));
self::assertResponseRedirects('/bookings?month='.$next->format('Y-m'));
}
public function testAMonthThatDoesNotExistIsNotFound(): void
{
$this->client->loginUser($this->user('[email protected]'));
// Two digits, so the route requirement lets it through; it would otherwise roll over into
// January of the following year.
$this->client->request('GET', '/bookings/month/2026-13');
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
public function testAMonthThatIsNotAMonthIsNotFound(): void
{
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/bookings/month/oktober');
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
public function testTheListBelowDoesNotNarrowToTheMonthOnScreen(): void
{
$anna = $this->user('[email protected]');
$date = $this->bookableDate();
$this->em->persist(new Booking($anna, $this->resource('D1'), $date));
$this->em->flush();
$this->client->loginUser($anna);
// A month with nothing in it. The calendar follows, the list must not.
$empty = $this->today->modify('first day of this month')->modify('+2 months');
$crawler = $this->client->request('GET', '/bookings?month='.$empty->format('Y-m'));
self::assertCount(1, $crawler->filter(\sprintf('#booking-calendar [data-date="%s"]', $empty->format('Y-m-d'))));
// "Freigeben" only exists on a list row, so its presence proves the row survived.
self::assertGreaterThan(0, $crawler->filter('#booking-calendar')->count());
self::assertStringContainsString('Freigeben', $crawler->filter('body')->text());
}
public function testPagingStopsAtTheCurrentMonthGoingBack(): void
{
$this->client->loginUser($this->user('[email protected]'));
$crawler = $this->client->request('GET', '/bookings');
self::assertCount(0, $crawler->filter('[aria-label="Voriger Monat"]'), 'Nothing before today is shown.');
self::assertCount(1, $crawler->filter('[aria-label="Nächster Monat"]'));
}
public function testPagingStopsAtTheBookingHorizonGoingForward(): void
{
$this->client->loginUser($this->user('[email protected]'));
$horizon = BookingHorizon::latest($this->today);
$crawler = $this->client->request('GET', '/bookings?month='.$horizon->format('Y-m'));
self::assertCount(0, $crawler->filter('[aria-label="Nächster Monat"]'), 'Nothing past the horizon can be booked.');
self::assertCount(1, $crawler->filter('[aria-label="Voriger Monat"]'));
}
public function testAMalformedMonthQueryFallsBackToThisMonth(): void
{
$this->client->loginUser($this->user('[email protected]'));
$crawler = $this->client->request('GET', '/bookings?month=nonsense');
self::assertResponseIsSuccessful();
self::assertCount(1, $crawler->filter(\sprintf('#booking-calendar [data-date="%s"]', $this->today->format('Y-m-d'))));
}
public function testAMonthWithoutBookingsStillRendersTheGrid(): void
{
$this->client->loginUser($this->user('[email protected]'));
$crawler = $this->client->request('GET', '/bookings');
self::assertResponseIsSuccessful();
self::assertCount(42, $crawler->filter('#booking-calendar [data-date]'));
self::assertCount(0, $crawler->filter('#booking-calendar a'));
}
/**
* A day that is both bookable and inside the month the page opens on -- tomorrow, unless that
* crosses into the next month, in which case yesterday's month would be shown instead.
*/
private function bookableDate(): \DateTimeImmutable
{
$tomorrow = $this->today->modify('+1 day');
return $tomorrow->format('Y-m') === $this->today->format('Y-m') ? $tomorrow : $this->today;
}
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;
}
}