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();
|
||||
}
|
||||
}
|
||||
@@ -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()]));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Booking\TimeRange;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceType;
|
||||
@@ -71,7 +72,7 @@ class BookingControllerTest extends WebTestCase
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01', '2026-10-01');
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01');
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
// The modal is gone from the response, which is what closes it: an out-of-band-only
|
||||
@@ -89,7 +90,7 @@ class BookingControllerTest extends WebTestCase
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', '2020-01-01');
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', ['allDay' => '1', 'date' => '2020-01-01']);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('id="htmx-modal"', (string) $response->getContent());
|
||||
@@ -107,7 +108,7 @@ class BookingControllerTest extends WebTestCase
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01', '2026-10-01');
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01');
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('ist am 01.10.2026 bereits belegt', (string) $response->getContent());
|
||||
@@ -121,7 +122,7 @@ class BookingControllerTest extends WebTestCase
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D2'), '2026-10-01', '2026-10-01');
|
||||
$response = $this->submitBookingModal($this->resource('D2'), '2026-10-01');
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('bereits D1 gebucht', (string) $response->getContent());
|
||||
@@ -135,7 +136,7 @@ class BookingControllerTest extends WebTestCase
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), '2026-10-01', '2026-10-01');
|
||||
$response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), '2026-10-01');
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
}
|
||||
@@ -183,9 +184,9 @@ class BookingControllerTest extends WebTestCase
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
|
||||
// Read the token off the rendered plan rather than minting one: outside a request there
|
||||
// is no session for the token storage to live in, and this is the real round trip anyway.
|
||||
$crawler = $this->client->request('GET', '/?tag=2026-10-01');
|
||||
// Releasing lives in the edit modal now, not on the floor plan -- clicking your own desk
|
||||
// opens that modal, and the token is rendered there.
|
||||
$crawler = $this->client->request('GET', \sprintf('/buchungen/%d/bearbeiten', $id), server: ['HTTP_HX-Request' => 'true']);
|
||||
$vals = $crawler->filter(\sprintf('[hx-post="/buchungen/%d/freigeben"]', $id))->attr('hx-vals');
|
||||
$token = json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
|
||||
|
||||
@@ -196,17 +197,229 @@ class BookingControllerTest extends WebTestCase
|
||||
self::assertNull($this->em->getRepository(Booking::class)->find($id));
|
||||
}
|
||||
|
||||
private function submitBookingModal(Resource $resource, string $routeDate, string $submittedDate): Response
|
||||
public function testTheFloorPlanOpensTheEditModalForYourOwnBooking(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$booking = new Booking($anna, $this->resource('D1', svgElementId: 'desk-01'), new \DateTimeImmutable('2026-10-01'));
|
||||
$this->em->persist($booking);
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
$crawler = $this->client->request('GET', '/?tag=2026-10-01');
|
||||
|
||||
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/buchungen/%d/bearbeiten"]', $booking->getId())));
|
||||
}
|
||||
|
||||
public function testBookingPartOfADay(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01', [
|
||||
'startTime' => '09:00',
|
||||
'endTime' => '13:00',
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
$booking = $this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]);
|
||||
self::assertNotNull($booking);
|
||||
self::assertSame('09:00–13:00', (string) $booking->getTimeRange());
|
||||
}
|
||||
|
||||
public function testABookingDefaultsToTheWholeDay(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$this->submitBookingModal($desk, '2026-10-01');
|
||||
|
||||
$booking = $this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]);
|
||||
self::assertNotNull($booking);
|
||||
self::assertTrue($booking->getTimeRange()->isAllDay());
|
||||
}
|
||||
|
||||
public function testAnEndBeforeTheStartIsRejected(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', [
|
||||
'startTime' => '13:00',
|
||||
'endTime' => '09:00',
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('Das Ende muss nach dem Beginn liegen.', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testAPartDayBookingLeavesTheRestOfTheDayBookable(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780)));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01', [
|
||||
'startTime' => '13:00',
|
||||
'endTime' => '17:00',
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testAWeeklySeriesIsCreatedAndReported(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
// 2026-10-05 is a Monday.
|
||||
$response = $this->submitBookingModal($desk, '2026-10-05', [
|
||||
'startTime' => '09:00',
|
||||
'endTime' => '12:00',
|
||||
'repeat' => 'weekly',
|
||||
'weekdays' => ['1', '4'],
|
||||
'until' => '2026-10-30',
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertCount(8, $this->em->getRepository(Booking::class)->findBy(['resource' => $desk]));
|
||||
self::assertStringContainsString('8 Termine gebucht', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testASeriesWithoutAnEndDateIsRejected(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
|
||||
'allDay' => '1',
|
||||
'repeat' => 'daily',
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('Bitte ein Enddatum für die Serie angeben.', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testAWeeklySeriesWithoutWeekdaysIsRejected(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
|
||||
'allDay' => '1',
|
||||
'repeat' => 'weekly',
|
||||
'until' => '2026-10-30',
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('Bitte mindestens einen Wochentag wählen.', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testASeriesRunningTooFarOutIsRejected(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
|
||||
'allDay' => '1',
|
||||
'repeat' => 'daily',
|
||||
'until' => '2030-01-01',
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('höchstens 12 Monate', (string) $response->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* The board's span is carried into the modal, so narrowing the board and clicking a desk
|
||||
* books exactly what was on screen.
|
||||
*/
|
||||
public function testTheModalIsPrefilledFromTheBoardsSpan(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$crawler = $this->client->request(
|
||||
'GET',
|
||||
\sprintf('/buchungen/neu/%d/%s', $resource->getId(), $routeDate),
|
||||
\sprintf('/buchungen/neu/%d/2026-10-01?von=09:00&bis=13:00', $desk->getId()),
|
||||
server: ['HTTP_HX-Request' => 'true'],
|
||||
);
|
||||
|
||||
self::assertSame('09:00', $crawler->filter('#booking_startTime')->attr('value'));
|
||||
self::assertSame('13:00', $crawler->filter('#booking_endTime')->attr('value'));
|
||||
self::assertNull($crawler->filter('#booking_allDay')->attr('checked'));
|
||||
}
|
||||
|
||||
public function testTheBoardNarrowsToASpan(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$this->client->request('GET', '/tag/2026-10-01?von=09:00&bis=13:00', server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame('/?tag=2026-10-01&von=09:00&bis=13:00', $this->client->getResponse()->headers->get('HX-Push-Url'));
|
||||
}
|
||||
|
||||
public function testOverridingOneOccurrenceLeavesTheSeriesAlone(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$this->client->loginUser($anna);
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$this->submitBookingModal($desk, '2026-10-05', [
|
||||
'startTime' => '09:00',
|
||||
'endTime' => '12:00',
|
||||
'repeat' => 'weekly',
|
||||
'weekdays' => ['1'],
|
||||
'until' => '2026-10-26',
|
||||
]);
|
||||
|
||||
$occurrences = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk], ['date' => 'ASC']);
|
||||
self::assertCount(4, $occurrences);
|
||||
$first = $occurrences[0];
|
||||
|
||||
$crawler = $this->client->request('GET', \sprintf('/buchungen/%d/bearbeiten', $first->getId()), server: ['HTTP_HX-Request' => 'true']);
|
||||
// Only the span is editable here: the day and the recurrence belong to the series.
|
||||
self::assertCount(0, $crawler->filter('#booking_date'));
|
||||
self::assertCount(0, $crawler->filter('#booking_repeat'));
|
||||
|
||||
$form = $crawler->selectButton('Speichern')->form([
|
||||
'booking[startTime]' => '14:00',
|
||||
'booking[endTime]' => '16:00',
|
||||
]);
|
||||
$this->client->submit($form, serverParameters: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$this->em->clear();
|
||||
$reloaded = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk], ['date' => 'ASC']);
|
||||
self::assertSame('14:00–16:00', (string) $reloaded[0]->getTimeRange());
|
||||
self::assertTrue($reloaded[0]->isOverridden());
|
||||
// The other three are untouched.
|
||||
foreach (\array_slice($reloaded, 1) as $untouched) {
|
||||
self::assertSame('09:00–12:00', (string) $untouched->getTimeRange());
|
||||
self::assertFalse($untouched->isOverridden());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the modal, reads its CSRF token, and posts the given booking fields back to it.
|
||||
*
|
||||
* Posted explicitly rather than through DomCrawler's form(): the weekday checkboxes are an
|
||||
* expanded multiple choice rendered as booking[weekdays][], which the crawler's field
|
||||
* registry indexes positionally, so assigning ['1', '4'] sets the second checkbox instead of
|
||||
* the fourth. The token still comes off the rendered form, so the round trip is real.
|
||||
*
|
||||
* @param array<string, mixed> $fields
|
||||
*/
|
||||
private function submitBookingModal(Resource $resource, string $routeDate, array $fields = ['allDay' => '1']): Response
|
||||
{
|
||||
$url = \sprintf('/buchungen/neu/%d/%s', $resource->getId(), $routeDate);
|
||||
$crawler = $this->client->request('GET', $url, server: ['HTTP_HX-Request' => 'true']);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$form = $crawler->selectButton('Buchen')->form(['booking[date]' => $submittedDate]);
|
||||
$this->client->submit($form, serverParameters: ['HTTP_HX-Request' => 'true']);
|
||||
$fields += [
|
||||
'date' => $routeDate,
|
||||
'_token' => $crawler->filter('#booking__token')->attr('value'),
|
||||
];
|
||||
|
||||
$this->client->request('POST', $url, ['booking' => $fields], server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
return $this->client->getResponse();
|
||||
}
|
||||
|
||||
@@ -4,18 +4,22 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Repository;
|
||||
|
||||
use App\Booking\TimeRange;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use App\Repository\BookingRepository;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* Hits the real database, because the behaviour under test is the database's: the unique
|
||||
* constraint is what actually prevents double-booking.
|
||||
* Hits the real database, because these are queries.
|
||||
*
|
||||
* Note what is NOT here any more: the double-booking guard. It used to be a unique index on
|
||||
* (resource, date) and was tested by asserting the insert blew up. A booking is now a time range,
|
||||
* which no unique index can guard, so the guard lives in BookingManager and is tested there --
|
||||
* what this class covers is the overlap query that guard relies on.
|
||||
*
|
||||
* Each test runs inside a transaction that is rolled back afterwards.
|
||||
*/
|
||||
@@ -38,7 +42,30 @@ class BookingRepositoryTest extends KernelTestCase
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testTheSameResourceCannotBeBookedTwiceOnOneDay(): void
|
||||
public function testFindOverlappingSeesASpanThatCollides(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$date = new \DateTimeImmutable('2026-10-01');
|
||||
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, $date, TimeRange::fromMinutes(9 * 60, 13 * 60)));
|
||||
$this->em->flush();
|
||||
|
||||
self::assertCount(1, $this->bookings->findOverlapping($desk, $date, TimeRange::fromMinutes(11 * 60, 15 * 60)));
|
||||
}
|
||||
|
||||
public function testFindOverlappingTreatsTouchingSpansAsFree(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$date = new \DateTimeImmutable('2026-10-01');
|
||||
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, $date, TimeRange::fromMinutes(9 * 60, 13 * 60)));
|
||||
$this->em->flush();
|
||||
|
||||
// Half-open: 09:00-13:00 and 13:00-17:00 do not collide.
|
||||
self::assertSame([], $this->bookings->findOverlapping($desk, $date, TimeRange::fromMinutes(13 * 60, 17 * 60)));
|
||||
}
|
||||
|
||||
public function testAnAllDayBookingBlocksEverySpan(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$date = new \DateTimeImmutable('2026-10-01');
|
||||
@@ -46,12 +73,7 @@ class BookingRepositoryTest extends KernelTestCase
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, $date));
|
||||
$this->em->flush();
|
||||
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, $date));
|
||||
|
||||
// The controller catches exactly this and re-renders the fragment with an inline error,
|
||||
// rather than pre-checking, which would be racy.
|
||||
$this->expectException(UniqueConstraintViolationException::class);
|
||||
$this->em->flush();
|
||||
self::assertCount(1, $this->bookings->findOverlapping($desk, $date, TimeRange::fromMinutes(8 * 60, 8 * 60 + 15)));
|
||||
}
|
||||
|
||||
public function testTheSameResourceOnAnotherDayIsFine(): void
|
||||
@@ -66,17 +88,31 @@ class BookingRepositoryTest extends KernelTestCase
|
||||
self::assertCount(1, $this->bookings->findForDate(new \DateTimeImmutable('2026-10-01')));
|
||||
}
|
||||
|
||||
public function testATimeComponentDoesNotCreateASecondSlotForTheSameDay(): void
|
||||
public function testATimeComponentOnTheDateDoesNotShiftWhichDayABookingIsOn(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01 08:00')));
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01 17:30')));
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
|
||||
self::assertCount(1, $this->bookings->findForDate(new \DateTimeImmutable('2026-10-01')));
|
||||
}
|
||||
|
||||
public function testFindForDateOnlyReturnsWhatOverlapsTheSpan(): void
|
||||
{
|
||||
$date = new \DateTimeImmutable('2026-10-01');
|
||||
$user = $this->user('[email protected]');
|
||||
|
||||
$this->persist(new Booking($user, $this->resource('D1'), $date, TimeRange::fromMinutes(9 * 60, 12 * 60)));
|
||||
$this->persist(new Booking($user, $this->resource('D2'), $date, TimeRange::fromMinutes(14 * 60, 17 * 60)));
|
||||
$this->em->flush();
|
||||
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01 17:30')));
|
||||
$morning = $this->bookings->findForDate($date, TimeRange::fromMinutes(8 * 60, 13 * 60));
|
||||
|
||||
$this->expectException(UniqueConstraintViolationException::class);
|
||||
$this->em->flush();
|
||||
self::assertCount(1, $morning);
|
||||
self::assertSame('D1', $morning[0]->getResource()->getLabel());
|
||||
self::assertCount(2, $this->bookings->findForDate($date));
|
||||
}
|
||||
|
||||
public function testFindForDateCarriesTheBookingHolderForTheTeamView(): void
|
||||
@@ -92,6 +128,24 @@ class BookingRepositoryTest extends KernelTestCase
|
||||
self::assertSame('D1', $bookings[0]->getResource()->getLabel());
|
||||
}
|
||||
|
||||
public function testCountPerDateOnlyCountsWhatOverlapsTheSpan(): void
|
||||
{
|
||||
$user = $this->user('[email protected]');
|
||||
$this->persist(new Booking($user, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(9 * 60, 12 * 60)));
|
||||
$this->persist(new Booking($this->user('[email protected]'), $this->resource('D2'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(14 * 60, 17 * 60)));
|
||||
$this->em->flush();
|
||||
|
||||
$counts = $this->bookings->countPerDateInRange(
|
||||
new \DateTimeImmutable('2026-10-01'),
|
||||
new \DateTimeImmutable('2026-10-31'),
|
||||
TimeRange::fromMinutes(8 * 60, 13 * 60),
|
||||
);
|
||||
|
||||
// The calendar number has to mean the same thing as the colours on the plan, so it is
|
||||
// filtered by the same span.
|
||||
self::assertSame(['2026-10-01' => 1], $counts);
|
||||
}
|
||||
|
||||
public function testCountPerDateInRangeIsKeyedByDay(): void
|
||||
{
|
||||
$user = $this->user('[email protected]');
|
||||
|
||||
Reference in new issue
Block a user