em = self::getContainer()->get(EntityManagerInterface::class); $this->manager = self::getContainer()->get(BookingManager::class); $this->wipe(); } protected function tearDown(): void { $this->wipe(); parent::tearDown(); } public function testAFreeResourceIsBooked(): void { $booking = $this->manager->book($this->user('a@ep-reisen.de'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780)); self::assertNotNull($booking->getId()); self::assertSame('09:00–13:00', (string) $booking->getTimeRange()); } public function testAnOverlappingSpanOnTheSameResourceIsRefused(): void { $desk = $this->resource('D1'); $this->manager->book($this->user('a@ep-reisen.de'), $desk, $this->date(), TimeRange::fromMinutes(540, 780)); $this->expectException(ResourceAlreadyBookedException::class); $this->manager->book($this->user('b@ep-reisen.de'), $desk, $this->date(), TimeRange::fromMinutes(660, 900)); } public function testATouchingSpanOnTheSameResourceIsAllowed(): void { $desk = $this->resource('D1'); $this->manager->book($this->user('a@ep-reisen.de'), $desk, $this->date(), TimeRange::fromMinutes(540, 780)); $second = $this->manager->book($this->user('b@ep-reisen.de'), $desk, $this->date(), TimeRange::fromMinutes(780, 1020)); self::assertNotNull($second->getId()); } /** * This was a refusal once. It is allowed now -- the second desk is usually for the customer * somebody is meeting -- and the overlap is reported as a flash instead, which * BookingControllerTest covers. */ public function testOnePersonMayHoldTwoDesksAtOnce(): void { $user = $this->user('a@ep-reisen.de'); $this->manager->book($user, $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780)); $second = $this->manager->book($user, $this->resource('D2'), $this->date(), TimeRange::fromMinutes(600, 720)); self::assertNotNull($second->getId()); } public function testOnePersonMayHoldTwoDesksAtDifferentTimes(): void { $user = $this->user('a@ep-reisen.de'); $this->manager->book($user, $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780)); $afternoon = $this->manager->book($user, $this->resource('D2'), $this->date(), TimeRange::fromMinutes(780, 1020)); self::assertNotNull($afternoon->getId()); } public function testADeskAndAParkingSpotAtTheSameTimeAreBothAllowed(): void { $user = $this->user('a@ep-reisen.de'); $time = TimeRange::fromMinutes(540, 780); $this->manager->book($user, $this->resource('D1'), $this->date(), $time); $parking = $this->manager->book($user, $this->resource('P1', ResourceType::PARKING), $this->date(), $time); self::assertNotNull($parking->getId()); } public function testASeriesMaterialisesOneBookingPerOccurrence(): void { // 2026-10-05 is a Monday; the Mondays up to the 30th are the 5th, 12th, 19th and 26th. $series = $this->weeklySeries(); $result = $this->manager->bookSeries($series); self::assertSame(4, $result->createdCount()); self::assertSame(0, $result->skippedCount()); foreach ($result->created as $booking) { self::assertSame(1, (int) $booking->getDate()->format('N')); self::assertSame($series, $booking->getSeries()); } } public function testATwoWeeklySeriesSkipsEveryOtherWeek(): void { $result = $this->manager->bookSeries(new BookingSeries( $this->user('a@ep-reisen.de'), $this->resource('D1'), RecurrenceRule::TWO_WEEKLY, new \DateTimeImmutable('2026-10-05'), new \DateTimeImmutable('2026-11-30'), TimeRange::fromMinutes(540, 720), )); self::assertSame(5, $result->createdCount()); self::assertSame( ['2026-10-05', '2026-10-19', '2026-11-02', '2026-11-16', '2026-11-30'], array_map(static fn (Booking $booking): string => $booking->getDate()->format('Y-m-d'), $result->created), ); } /** * The property the weekday field was removed in favour of: a stride that is a whole number of * weeks lands on the same weekday every time, so nothing has to store which one it is. A * four-weekly series is the one where this is easy to get wrong by reaching for months. */ public function testAFourWeeklySeriesAlwaysLandsOnTheSameWeekday(): void { $result = $this->manager->bookSeries(new BookingSeries( $this->user('a@ep-reisen.de'), $this->resource('D1'), RecurrenceRule::FOUR_WEEKLY, new \DateTimeImmutable('2026-10-05'), new \DateTimeImmutable('2026-12-31'), TimeRange::fromMinutes(540, 720), )); self::assertSame(4, $result->createdCount()); foreach ($result->created as $booking) { self::assertSame(1, (int) $booking->getDate()->format('N'), 'every occurrence is a Monday'); } // Crossing a month boundary is where a "monthly" reading would diverge: 28 days after // 2 November is 30 November, not 2 December. self::assertSame('2026-11-30', $result->created[2]->getDate()->format('Y-m-d')); } public function testADailySeriesCoversEveryDay(): void { $result = $this->manager->bookSeries(new BookingSeries( $this->user('a@ep-reisen.de'), $this->resource('D1'), RecurrenceRule::DAILY, new \DateTimeImmutable('2026-10-05'), new \DateTimeImmutable('2026-10-11'), TimeRange::fromMinutes(540, 720), )); self::assertSame(7, $result->createdCount()); } /** * The point of materialising: a taken day is reported rather than failing the whole series. */ public function testASeriesSkipsDaysThatAreAlreadyTaken(): void { $desk = $this->resource('D1'); // The 12th is one of the series' own Mondays. $this->manager->book($this->user('b@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-12'), TimeRange::fromMinutes(600, 660)); $result = $this->manager->bookSeries(new BookingSeries( $this->user('a@ep-reisen.de'), $desk, RecurrenceRule::WEEKLY, new \DateTimeImmutable('2026-10-05'), new \DateTimeImmutable('2026-10-30'), TimeRange::fromMinutes(540, 720), )); self::assertSame(3, $result->createdCount()); self::assertSame(['12.10.'], $result->skippedDates()); self::assertFalse($result->isCompletelyBlocked()); } public function testASeriesThatCollidesEverywhereReportsItself(): void { $desk = $this->resource('D1'); $this->manager->book($this->user('b@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-05')); $result = $this->manager->bookSeries(new BookingSeries( $this->user('a@ep-reisen.de'), $desk, RecurrenceRule::WEEKLY, new \DateTimeImmutable('2026-10-05'), new \DateTimeImmutable('2026-10-09'), TimeRange::fromMinutes(540, 720), [1], )); self::assertTrue($result->isCompletelyBlocked()); self::assertSame(0, $result->createdCount()); } public function testReschedulingAnOccurrenceMarksItAsAnOverride(): void { $series = $this->weeklySeries(); $occurrence = $this->manager->bookSeries($series)->created[0]; $this->manager->reschedule($occurrence, TimeRange::fromMinutes(840, 960)); self::assertTrue($occurrence->isOverridden()); self::assertSame('14:00–16:00', (string) $occurrence->getTimeRange()); } public function testReschedulingBackOntoTheSeriesTimesClearsTheOverride(): void { $series = $this->weeklySeries(); $occurrence = $this->manager->bookSeries($series)->created[0]; $this->manager->reschedule($occurrence, TimeRange::fromMinutes(840, 960)); $this->manager->reschedule($occurrence, $series->getTimeRange()); self::assertFalse($occurrence->isOverridden()); } public function testReschedulingIntoSomebodyElsesSpanIsRefused(): void { $desk = $this->resource('D1'); $mine = $this->manager->book($this->user('a@ep-reisen.de'), $desk, $this->date(), TimeRange::fromMinutes(540, 780)); $this->manager->book($this->user('b@ep-reisen.de'), $desk, $this->date(), TimeRange::fromMinutes(780, 1020)); $this->expectException(ResourceAlreadyBookedException::class); $this->manager->reschedule($mine, TimeRange::fromMinutes(540, 900)); } /** * Rescheduling must not trip over the booking it is moving -- that would make every edit * that keeps any part of its own span impossible. */ public function testAnOccurrenceCanBeShrunkWithoutCollidingWithItself(): void { $booking = $this->manager->book($this->user('a@ep-reisen.de'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780)); $this->manager->reschedule($booking, TimeRange::fromMinutes(600, 720)); self::assertSame('10:00–12:00', (string) $booking->getTimeRange()); } public function testEndingASeriesRemovesItsRemainingOccurrences(): void { $series = $this->weeklySeries(); $this->manager->bookSeries($series); $id = $series->getId(); $removed = $this->manager->endSeries($series, new \DateTimeImmutable('2026-10-05')); self::assertSame(4, $removed); self::assertSame([], $this->em->getRepository(Booking::class)->findBy(['series' => $id])); // Nothing of the series is left, so the series row goes too -- note $series has no id // any more at this point, which is why the lookup goes through the captured one. self::assertNull($this->em->getRepository(BookingSeries::class)->find($id)); } public function testEndingASeriesPartWayThroughKeepsThePastAndShortensIt(): void { $series = $this->weeklySeries(); $this->manager->bookSeries($series); // Leaves the Mondays on the 5th and 12th, removes the 19th and 26th. $removed = $this->manager->endSeries($series, new \DateTimeImmutable('2026-10-15')); self::assertSame(2, $removed); self::assertCount(2, $this->em->getRepository(Booking::class)->findBy(['series' => $series])); self::assertSame('2026-10-14', $series->getEndDate()->format('Y-m-d')); } public function testABookingNotifiesTheRecipientsOfItsType(): void { $this->recipientsFor(ResourceType::DESK, ['hausmeister@ep-reisen.de', 'empfang@ep-reisen.de']); $this->manager->book($this->user('a@ep-reisen.de'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780)); // One mail each, addressed to them alone: no recipient learns who else is on the list. self::assertEmailCount(2); $emails = self::getMailerMessages(); self::assertSame( ['hausmeister@ep-reisen.de', 'empfang@ep-reisen.de'], array_map(static fn ($email): string => $email->getTo()[0]->getAddress(), $emails), ); foreach ($emails as $email) { self::assertCount(1, $email->getTo()); self::assertSame([], $email->getBcc()); self::assertSame('Neue Buchung: D1 am 01.10.2026', $email->getSubject()); self::assertStringContainsString('D1', $email->getTextBody()); // Both parts render: the MyE&P layout, and the text fallback beside it. self::assertStringContainsString('D1', $email->getHtmlBody()); self::assertStringContainsString('Neue Buchung', $email->getHtmlBody()); // The logo resolved through the @images Twig path and was embedded, not hotlinked. self::assertStringContainsString('cid:', $email->getHtmlBody()); } } public function testAKindWithNoRecipientsNotifiesNobody(): void { $this->recipientsFor(ResourceType::PARKING, ['empfang@ep-reisen.de']); $this->manager->book($this->user('a@ep-reisen.de'), $this->resource('D1'), $this->date()); self::assertEmailCount(0); } /** * One mail per recipient for the series rather than one per occurrence -- four Mondays and two * recipients make two mails, and a yearly series would otherwise be a hundred and four. */ public function testASeriesSendsOneMailPerRecipientNotPerOccurrence(): void { $this->recipientsFor(ResourceType::DESK, ['hausmeister@ep-reisen.de', 'empfang@ep-reisen.de']); $result = $this->manager->bookSeries($this->weeklySeries()); self::assertSame(4, $result->createdCount()); self::assertEmailCount(2); self::assertStringContainsString('4', (string) self::getMailerMessage()?->getTextBody()); } public function testASeriesThatBookedNothingNotifiesNobody(): void { $this->recipientsFor(ResourceType::DESK, ['empfang@ep-reisen.de']); $desk = $this->resource('D1'); $this->manager->book($this->user('b@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-05')); $result = $this->manager->bookSeries(new BookingSeries( $this->user('a@ep-reisen.de'), $desk, RecurrenceRule::WEEKLY, new \DateTimeImmutable('2026-10-05'), new \DateTimeImmutable('2026-10-09'), TimeRange::fromMinutes(540, 720), )); self::assertTrue($result->isCompletelyBlocked()); // The one booking above is the only mail-worthy event, and it happened before recipients // could matter for the series -- so exactly one mail, for that booking. self::assertEmailCount(1); } public function testABookingRecordsAnEvent(): void { $booking = $this->manager->book($this->user('a@ep-reisen.de'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780)); $event = $this->onlyEvent(); self::assertSame(BookingEventType::BOOKING_CREATED, $event->getType()); self::assertSame($booking->getId(), $event->getBookingId()); self::assertSame('D1', $event->getResourceLabel()); self::assertSame('Test', $event->getHolderName()); self::assertSame('2026-10-01', $event->getDate()?->format('Y-m-d')); self::assertSame('09:00–13:00', (string) $event->getTimeRange()); // No token in a kernel test, as for a console command. self::assertNull($event->getActor()); self::assertSame(BookingEvent::SYSTEM_ACTOR, $event->getActorName()); } /** * The event is written in the booking's transaction, so a refusal leaves nothing behind. */ public function testARefusedBookingRecordsNoEvent(): void { $desk = $this->resource('D1'); $this->manager->book($this->user('a@ep-reisen.de'), $desk, $this->date()); try { $this->manager->book($this->user('b@ep-reisen.de'), $desk, $this->date()); self::fail('The second booking should have been refused.'); } catch (ResourceAlreadyBookedException) { } self::assertSame(1, $this->em->getRepository(BookingEvent::class)->count([])); } public function testReschedulingRecordsTheOldAndTheNewTimes(): void { $booking = $this->manager->book($this->user('a@ep-reisen.de'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780)); $this->manager->reschedule($booking, TimeRange::fromMinutes(600, 720)); $event = $this->latestEvent(); self::assertSame(BookingEventType::BOOKING_RESCHEDULED, $event->getType()); self::assertSame('09:00–13:00', (string) $event->getPreviousTimeRange()); self::assertSame('10:00–12:00', (string) $event->getTimeRange()); } public function testReleasingRecordsTheBookingThatWent(): void { $booking = $this->manager->book($this->user('a@ep-reisen.de'), $this->resource('D1'), $this->date()); $id = $booking->getId(); $this->manager->release($booking); $event = $this->latestEvent(); self::assertSame(BookingEventType::BOOKING_RELEASED, $event->getType()); self::assertSame($id, $event->getBookingId()); self::assertSame('D1', $event->getResourceLabel()); } /** * One event for the series, not one per day -- the same rule as the audit log. */ public function testASeriesRecordsOneEvent(): void { $series = $this->weeklySeries(); $this->manager->bookSeries($series); $event = $this->onlyEvent(); self::assertSame(BookingEventType::SERIES_CREATED, $event->getType()); self::assertSame($series->getId(), $event->getSeriesId()); self::assertSame(RecurrenceRule::WEEKLY, $event->getRule()); self::assertSame(4, $event->getDetails()['created']); self::assertSame('2026-10-30', $event->getEndDate()?->format('Y-m-d')); } /** * Ending a series that has nothing left deletes it, so the id has to survive from before. */ public function testEndingASeriesRecordsItsIdEvenWhenTheSeriesIsGone(): void { $series = $this->weeklySeries(); $this->manager->bookSeries($series); $id = $series->getId(); $this->manager->endSeries($series, new \DateTimeImmutable('2026-10-05')); $event = $this->latestEvent(); self::assertSame(BookingEventType::SERIES_ENDED, $event->getType()); self::assertSame($id, $event->getSeriesId()); self::assertSame(4, $event->getDetails()['removed']); self::assertSame('2026-10-05', $event->getDate()?->format('Y-m-d')); } /** * @param list $recipients */ private function recipientsFor(ResourceType $type, array $recipients): void { $settings = new ResourceTypeSettings($type); $settings->setNotificationRecipients($recipients); $this->em->persist($settings); $this->em->flush(); } private function weeklySeries(): BookingSeries { return new BookingSeries( $this->user('a@ep-reisen.de'), $this->resource('D1'), RecurrenceRule::WEEKLY, new \DateTimeImmutable('2026-10-05'), new \DateTimeImmutable('2026-10-30'), TimeRange::fromMinutes(540, 720), ); } private function date(): \DateTimeImmutable { return new \DateTimeImmutable('2026-10-01'); } private function user(string $email): User { $user = new User($email, 'Test'); $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; } private function onlyEvent(): BookingEvent { $events = $this->em->getRepository(BookingEvent::class)->findAll(); self::assertCount(1, $events); return $events[0]; } private function latestEvent(): BookingEvent { $event = $this->em->getRepository(BookingEvent::class)->findOneBy([], ['id' => 'DESC']); self::assertInstanceOf(BookingEvent::class, $event); return $event; } private function wipe(): void { $connection = $this->em->getConnection(); $connection->executeStatement('DELETE FROM booking_event'); $connection->executeStatement('DELETE FROM booking'); $connection->executeStatement('DELETE FROM booking_series'); $connection->executeStatement('DELETE FROM resource'); $connection->executeStatement('DELETE FROM resource_type_settings'); $connection->executeStatement('DELETE FROM `user`'); $this->em->clear(); } }