541 lines
21 KiB
PHP
541 lines
21 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Tests\Service;
|
||
|
||
use App\Entity\Booking;
|
||
use App\Entity\BookingEvent;
|
||
use App\Entity\BookingSeries;
|
||
use App\Entity\Resource;
|
||
use App\Entity\ResourceTypeSettings;
|
||
use App\Entity\User;
|
||
use App\Enum\BookingEventType;
|
||
use App\Enum\RecurrenceRule;
|
||
use App\Enum\ResourceType;
|
||
use App\Exception\ResourceAlreadyBookedException;
|
||
use App\Model\TimeRange;
|
||
use App\Service\BookingManager;
|
||
use Doctrine\ORM\EntityManagerInterface;
|
||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||
use Symfony\Bundle\FrameworkBundle\Test\MailerAssertionsTrait;
|
||
|
||
/**
|
||
* The double-booking guard now lives here rather than in a unique index, so this is where it is
|
||
* tested. BookingManager opens its own transactions (it locks the resource row FOR UPDATE), so
|
||
* these tests cannot wrap themselves in one -- a rollback would fight the manager's commits.
|
||
* They clean up explicitly instead.
|
||
*/
|
||
class BookingManagerTest extends KernelTestCase
|
||
{
|
||
use MailerAssertionsTrait;
|
||
|
||
private EntityManagerInterface $em;
|
||
private BookingManager $manager;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
self::bootKernel();
|
||
$this->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('[email protected]'), $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('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(540, 780));
|
||
|
||
$this->expectException(ResourceAlreadyBookedException::class);
|
||
$this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(660, 900));
|
||
}
|
||
|
||
public function testATouchingSpanOnTheSameResourceIsAllowed(): void
|
||
{
|
||
$desk = $this->resource('D1');
|
||
$this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(540, 780));
|
||
|
||
$second = $this->manager->book($this->user('[email protected]'), $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('[email protected]');
|
||
$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('[email protected]');
|
||
$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('[email protected]');
|
||
$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('[email protected]'),
|
||
$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('[email protected]'),
|
||
$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('[email protected]'),
|
||
$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('[email protected]'), $desk, new \DateTimeImmutable('2026-10-12'), TimeRange::fromMinutes(600, 660));
|
||
|
||
$result = $this->manager->bookSeries(new BookingSeries(
|
||
$this->user('[email protected]'),
|
||
$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('[email protected]'), $desk, new \DateTimeImmutable('2026-10-05'));
|
||
|
||
$result = $this->manager->bookSeries(new BookingSeries(
|
||
$this->user('[email protected]'),
|
||
$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('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(540, 780));
|
||
$this->manager->book($this->user('[email protected]'), $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('[email protected]'), $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, ['[email protected]', '[email protected]']);
|
||
|
||
$this->manager->book($this->user('[email protected]'), $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(
|
||
['[email protected]', '[email protected]'],
|
||
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, ['[email protected]']);
|
||
|
||
$this->manager->book($this->user('[email protected]'), $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, ['[email protected]', '[email protected]']);
|
||
|
||
$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, ['[email protected]']);
|
||
$desk = $this->resource('D1');
|
||
$this->manager->book($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-05'));
|
||
|
||
$result = $this->manager->bookSeries(new BookingSeries(
|
||
$this->user('[email protected]'),
|
||
$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('[email protected]'), $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('[email protected]'), $desk, $this->date());
|
||
|
||
try {
|
||
$this->manager->book($this->user('[email protected]'), $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('[email protected]'), $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('[email protected]'), $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<string> $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('[email protected]'),
|
||
$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();
|
||
}
|
||
}
|