feat: show my bookings on a month calendar
This commit is contained in:
1 parent
5a8227f4d9
commit
4517b84023
12 files changed
+891
-21
No files matched your search
@@ -0,0 +1,323 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Model;
|
||||
|
||||
use App\Model\MonthGrid;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class MonthGridTest extends TestCase
|
||||
{
|
||||
public function testTheGridIsAlwaysSixWeeksOfSevenDays(): void
|
||||
{
|
||||
// February 2026 needs four rows; the grid still renders six, so paging never changes its
|
||||
// height.
|
||||
$grid = MonthGrid::around(new \DateTimeImmutable('2026-02-15'), new \DateTimeImmutable('2026-02-15'));
|
||||
|
||||
self::assertCount(6, $grid->weeks);
|
||||
|
||||
foreach ($grid->weeks as $week) {
|
||||
self::assertCount(7, $week);
|
||||
}
|
||||
}
|
||||
|
||||
public function testItStartsOnTheMondayOnOrBeforeTheFirst(): void
|
||||
{
|
||||
// 1 September 2026 is a Tuesday, so the grid opens on 31 August.
|
||||
$grid = MonthGrid::around(new \DateTimeImmutable('2026-09-21'), new \DateTimeImmutable('2026-09-21'));
|
||||
|
||||
self::assertSame('2026-08-31', $grid->gridStart->format('Y-m-d'));
|
||||
self::assertSame('2026-09-01', $grid->monthStart->format('Y-m-d'));
|
||||
self::assertSame('1', $grid->gridStart->format('N'));
|
||||
}
|
||||
|
||||
public function testAMonthBeginningOnAMondayStartsOnThatMonday(): void
|
||||
{
|
||||
// 1 June 2026 is a Monday: no leading days at all.
|
||||
$grid = MonthGrid::around(new \DateTimeImmutable('2026-06-10'), new \DateTimeImmutable('2026-06-10'));
|
||||
|
||||
self::assertSame('2026-06-01', $grid->gridStart->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function testTheEndIsTheLastCellOfTheGrid(): void
|
||||
{
|
||||
$grid = MonthGrid::around(new \DateTimeImmutable('2026-09-21'), new \DateTimeImmutable('2026-09-21'));
|
||||
|
||||
self::assertSame('2026-10-11', $grid->gridEnd->format('Y-m-d'));
|
||||
self::assertEquals($grid->weeks[5][6]->date, $grid->gridEnd);
|
||||
}
|
||||
|
||||
public function testEveryCellIsTheDayAfterTheOneBefore(): void
|
||||
{
|
||||
// Guards the modify('+1 day') walk: March and October carry the DST switches, where naive
|
||||
// arithmetic on timestamps would repeat or skip a day.
|
||||
foreach (['2026-03-15', '2026-10-15'] as $inMonth) {
|
||||
$grid = MonthGrid::around(new \DateTimeImmutable($inMonth), new \DateTimeImmutable($inMonth));
|
||||
|
||||
$expected = $grid->gridStart;
|
||||
foreach ($grid->weeks as $week) {
|
||||
foreach ($week as $day) {
|
||||
self::assertSame($expected->format('Y-m-d'), $day->date->format('Y-m-d'));
|
||||
self::assertSame('00:00:00', $day->date->format('H:i:s'));
|
||||
$expected = $expected->modify('+1 day');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testDaysOutsideTheMonthAreFlagged(): void
|
||||
{
|
||||
$grid = MonthGrid::around(new \DateTimeImmutable('2026-09-21'), new \DateTimeImmutable('2026-09-21'));
|
||||
|
||||
self::assertFalse($grid->weeks[0][0]->inMonth, '31 August is a leading day.');
|
||||
self::assertTrue($grid->weeks[0][1]->inMonth, '1 September is in the month.');
|
||||
self::assertFalse($grid->weeks[5][6]->inMonth, '11 October is a trailing day.');
|
||||
}
|
||||
|
||||
public function testTodayIsNeitherPastNorAnyOtherDay(): void
|
||||
{
|
||||
$today = new \DateTimeImmutable('2026-09-21');
|
||||
$grid = MonthGrid::around($today, $today);
|
||||
|
||||
$flagged = [];
|
||||
foreach ($grid->weeks as $week) {
|
||||
foreach ($week as $day) {
|
||||
if ($day->isToday) {
|
||||
$flagged[] = $day->date->format('Y-m-d');
|
||||
self::assertFalse($day->isPast, 'Today is not past.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::assertSame(['2026-09-21'], $flagged);
|
||||
}
|
||||
|
||||
public function testPastAndWeekendFlags(): void
|
||||
{
|
||||
$grid = MonthGrid::around(new \DateTimeImmutable('2026-09-21'), new \DateTimeImmutable('2026-09-21'));
|
||||
|
||||
$days = [];
|
||||
foreach ($grid->weeks as $week) {
|
||||
foreach ($week as $day) {
|
||||
$days[$day->key()] = $day;
|
||||
}
|
||||
}
|
||||
|
||||
self::assertTrue($days['2026-09-20']->isPast);
|
||||
self::assertFalse($days['2026-09-22']->isPast);
|
||||
// 19/20 September 2026 is a Saturday/Sunday.
|
||||
self::assertTrue($days['2026-09-19']->isWeekend);
|
||||
self::assertTrue($days['2026-09-20']->isWeekend);
|
||||
self::assertFalse($days['2026-09-21']->isWeekend);
|
||||
}
|
||||
|
||||
public function testTheTimePartOfTheGivenDayIsIgnored(): void
|
||||
{
|
||||
$grid = MonthGrid::around(new \DateTimeImmutable('2026-09-21 17:43:12'), new \DateTimeImmutable('2026-09-21'));
|
||||
|
||||
self::assertSame('2026-09-01 00:00:00', $grid->monthStart->format('Y-m-d H:i:s'));
|
||||
self::assertTrue($grid->weeks[3][0]->isToday, 'Today must still be recognised.');
|
||||
}
|
||||
|
||||
public function testTheKeyIsTheDayItIndexesPayloadBy(): void
|
||||
{
|
||||
$grid = MonthGrid::around(new \DateTimeImmutable('2026-09-21'), new \DateTimeImmutable('2026-09-21'));
|
||||
|
||||
self::assertSame('2026-08-31', $grid->weeks[0][0]->key());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user