Files
ephub/tests/Booking/BookingManagerTest.php
T

301 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Tests\Booking;
use App\Booking\AlreadyHoldingResourceException;
use App\Booking\BookingManager;
use App\Booking\RecurrenceRule;
use App\Booking\ResourceAlreadyBookedException;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* 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
{
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());
}
public function testOnePersonMayNotHoldTwoDesksAtOnce(): void
{
$user = $this->user('[email protected]');
$this->manager->book($user, $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
$this->expectException(AlreadyHoldingResourceException::class);
$this->manager->book($user, $this->resource('D2'), $this->date(), TimeRange::fromMinutes(600, 720));
}
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.
$series = 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),
[1, 4],
);
$result = $this->manager->bookSeries($series);
self::assertSame(8, $result->createdCount());
self::assertSame(0, $result->skippedCount());
foreach ($result->created as $booking) {
self::assertContains((int) $booking->getDate()->format('N'), [1, 4]);
self::assertSame($series, $booking->getSeries());
}
}
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');
$this->manager->book($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-08'), 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),
[1, 4],
));
self::assertSame(7, $result->createdCount());
self::assertSame(['8.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(8, $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);
$removed = $this->manager->endSeries($series, new \DateTimeImmutable('2026-10-15'));
self::assertSame(5, $removed);
self::assertCount(3, $this->em->getRepository(Booking::class)->findBy(['series' => $series]));
self::assertSame('2026-10-14', $series->getEndDate()->format('Y-m-d'));
}
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),
[1, 4],
);
}
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, strtolower($label));
$this->em->persist($resource);
$this->em->flush();
return $resource;
}
private function wipe(): void
{
$connection = $this->em->getConnection();
$connection->executeStatement('DELETE FROM booking');
$connection->executeStatement('DELETE FROM booking_series');
$connection->executeStatement('DELETE FROM resource');
$connection->executeStatement('DELETE FROM `user`');
$this->em->clear();
}
}