feat: add booking time ranges and repeating series

This commit is contained in:
fromme committed 2026-09-13 17:34:28 +02:00
1 parent 59feca2f8e
commit d433d271d0
29 files changed
+2135 -180

No files matched your search

+300
View File
@@ -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();
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Tests\Booking;
use App\Booking\TimeRange;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
class TimeRangeTest extends TestCase
{
public function testAWholeDayRunsFromMidnightToMidnight(): void
{
$range = TimeRange::wholeDay();
self::assertTrue($range->isAllDay());
self::assertSame(0, $range->startMinute);
self::assertSame(1440, $range->endMinute);
}
public function testTheEndMustComeAfterTheStart(): void
{
$this->expectException(\InvalidArgumentException::class);
TimeRange::fromMinutes(13 * 60, 9 * 60);
}
public function testARangeCannotLeaveTheDay(): void
{
$this->expectException(\InvalidArgumentException::class);
TimeRange::fromMinutes(0, 1441);
}
public function testTouchingRangesDoNotOverlap(): void
{
$morning = TimeRange::fromMinutes(9 * 60, 13 * 60);
$afternoon = TimeRange::fromMinutes(13 * 60, 17 * 60);
// Half-open, so handing a desk over at 13:00 is not a collision.
self::assertFalse($morning->overlaps($afternoon));
self::assertFalse($afternoon->overlaps($morning));
}
public function testPartlyCoveringRangesOverlapBothWays(): void
{
$a = TimeRange::fromMinutes(9 * 60, 13 * 60);
$b = TimeRange::fromMinutes(12 * 60, 17 * 60);
self::assertTrue($a->overlaps($b));
self::assertTrue($b->overlaps($a));
}
public function testAWholeDayOverlapsEverything(): void
{
self::assertTrue(TimeRange::wholeDay()->overlaps(TimeRange::fromMinutes(0, 15)));
}
#[DataProvider('urlPairs')]
public function testParsingTheBoardsQueryParameters(?string $from, ?string $to, ?string $expected): void
{
$range = TimeRange::parse($from, $to);
self::assertSame($expected, null === $range ? null : $range->start().'-'.$range->end());
}
/**
* @return iterable<string, array{?string, ?string, ?string}>
*/
public static function urlPairs(): iterable
{
yield 'both given' => ['09:00', '13:00', '09:00-13:00'];
yield 'only a start is open to the end of the day' => ['09:00', null, '09:00-24:00'];
yield 'only an end is open from midnight' => [null, '13:00', '00:00-13:00'];
yield 'neither means the whole day, i.e. no filter' => [null, null, null];
yield 'empty strings are the same as absent' => ['', '', null];
yield 'reversed is rejected' => ['13:00', '09:00', null];
yield 'equal is rejected' => ['09:00', '09:00', null];
yield 'nonsense is rejected' => ['halb zehn', '13:00', null];
yield 'out of range is rejected' => ['25:00', '26:00', null];
}
/**
* Regression guard for a trap that cost real debugging time: Twig resolves `range.allDay` by
* looking for a method literally named `allDay` BEFORE it tries `isAllDay()`, and it happily
* calls a static one. While the factory was called allDay(), every template read back a
* freshly built all-day range -- always truthy -- so the whole board silently ignored the
* selected span. Renaming the factory to wholeDay() is what fixes it; this keeps it fixed.
*/
public function testTheAllDayAccessorIsNotShadowedInTwig(): void
{
$twig = new Environment(new ArrayLoader(['t' => '{{ range.allDay ? "yes" : "no" }}']));
self::assertSame('no', $twig->render('t', ['range' => TimeRange::fromMinutes(9 * 60, 13 * 60)]));
self::assertSame('yes', $twig->render('t', ['range' => TimeRange::wholeDay()]));
}
}