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('anna@ep-reisen.de')); $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('anna@ep-reisen.de'); $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('anna@ep-reisen.de'); $date = $this->bookableDate(); $this->em->persist(new Booking($anna, $this->resource('MINE'), $date)); $this->em->persist(new Booking($this->user('bea@ep-reisen.de'), $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('anna@ep-reisen.de'); $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('anna@ep-reisen.de'); $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('anna@ep-reisen.de'); $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('anna@ep-reisen.de'); $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('anna@ep-reisen.de'); $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('anna@ep-reisen.de')); $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('headers->get('HX-Push-Url')); } public function testPagingTheMonthWithoutHtmxRedirectsToTheShareablePage(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $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('anna@ep-reisen.de')); // 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('anna@ep-reisen.de')); $this->client->request('GET', '/bookings/month/oktober'); self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); } public function testTheListBelowDoesNotNarrowToTheMonthOnScreen(): void { $anna = $this->user('anna@ep-reisen.de'); $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('anna@ep-reisen.de')); $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('anna@ep-reisen.de')); $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('anna@ep-reisen.de')); $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('anna@ep-reisen.de')); $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; } }