feat: add booking time ranges and repeating series
This commit is contained in:
1 parent
59feca2f8e
commit
d433d271d0
29 files changed
+2135
-180
No files matched your search
@@ -0,0 +1,300 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user