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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user