feat: add admin views, a booking horizon and confirmation modals
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
8caf618950
commit
1a3640b15c
27 files changed
+1269
-62
No files matched your search
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Booking\Horizon;
|
||||
use App\Booking\TimeRange;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
@@ -184,16 +185,20 @@ class BookingControllerTest extends WebTestCase
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
|
||||
// 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.
|
||||
// The whole two-step flow: the edit modal opens the confirmation modal, and the
|
||||
// confirmation modal is where the token and the POST live.
|
||||
$crawler = $this->client->request('GET', \sprintf('/bookings/%d/edit', $id), server: ['HTTP_HX-Request' => 'true']);
|
||||
$vals = $crawler->filter(\sprintf('[hx-post="/bookings/%d/release"]', $id))->attr('hx-vals');
|
||||
$token = json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
|
||||
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/release"]', $id)));
|
||||
|
||||
$token = $this->confirmationToken(\sprintf('/bookings/%d/release', $id));
|
||||
|
||||
$this->client->request('POST', \sprintf('/bookings/%d/release', $id), ['_token' => $token], server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
$content = (string) $this->client->getResponse()->getContent();
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame(3, substr_count((string) $this->client->getResponse()->getContent(), 'hx-swap-oob="true"'));
|
||||
self::assertSame(3, substr_count($content, 'hx-swap-oob="true"'));
|
||||
// The confirmation swaps itself away; the edit modal underneath it has to go too.
|
||||
self::assertStringContainsString('id="htmx-modal" hx-swap-oob="delete"', $content);
|
||||
self::assertNull($this->em->getRepository(Booking::class)->find($id));
|
||||
}
|
||||
|
||||
@@ -345,14 +350,23 @@ class BookingControllerTest extends WebTestCase
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$response = $this->submitBookingModal($desk, '2026-10-05', [
|
||||
// Relative to today, unlike the rest of this file: the three-month horizon moves with the
|
||||
// clock, so a fixed end date here would start failing the day it fell outside it.
|
||||
$start = (new \DateTimeImmutable('today'))->modify('next monday');
|
||||
$until = $start->modify('+56 days');
|
||||
|
||||
$response = $this->submitBookingModal($desk, $start->format('Y-m-d'), [
|
||||
'allDay' => '1',
|
||||
'repeat' => 'four_weekly',
|
||||
'until' => '2026-12-31',
|
||||
'until' => $until->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertCount(4, $this->em->getRepository(Booking::class)->findBy(['resource' => $desk]));
|
||||
$occurrences = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk]);
|
||||
self::assertCount(3, $occurrences);
|
||||
foreach ($occurrences as $booking) {
|
||||
self::assertSame(1, (int) $booking->getDate()->format('N'));
|
||||
}
|
||||
}
|
||||
|
||||
public function testASeriesWithoutAnEndDateIsRejected(): void
|
||||
@@ -379,7 +393,86 @@ class BookingControllerTest extends WebTestCase
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('höchstens 12 Monate', (string) $response->getContent());
|
||||
self::assertStringContainsString('höchstens 3 Monate', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testABookingBeyondTheHorizonIsRejected(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$beyond = Horizon::latest()->modify('+1 day');
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), $beyond->format('Y-m-d'), ['allDay' => '1']);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('höchstens 3 Monate im Voraus', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testTheLastDayInsideTheHorizonIsStillBookable(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), Horizon::latest()->format('Y-m-d'), ['allDay' => '1']);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* The hole in the old rule, which measured the cap from the series' own start date: starting
|
||||
* later simply pushed the horizon out with it. This is the property that would regress
|
||||
* silently if the measurement ever moved back onto $this->date.
|
||||
*/
|
||||
public function testASeriesCannotPushTheHorizonOutByStartingLater(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$start = (new \DateTimeImmutable('today'))->modify('+2 months');
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), $start->format('Y-m-d'), [
|
||||
'allDay' => '1',
|
||||
'repeat' => 'weekly',
|
||||
'until' => $start->modify('+2 months')->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('höchstens 3 Monate in die Zukunft', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testAnAdminIsNotBoundByTheHorizon(): void
|
||||
{
|
||||
$this->client->loginUser($this->admin('[email protected]'));
|
||||
$beyond = Horizon::latest()->modify('+2 months');
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), $beyond->format('Y-m-d'), ['allDay' => '1']);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Past days stay clickable -- looking back at a plan is legitimate -- but they have to say
|
||||
* that nothing can be booked there, which they did not before.
|
||||
*/
|
||||
public function testPastDaysInTheCalendarAreMarkedButStillNavigable(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$yesterday = (new \DateTimeImmutable('today'))->modify('-1 day')->format('Y-m-d');
|
||||
|
||||
$crawler = $this->client->request('GET', '/book/desk');
|
||||
$cell = $crawler->filter(\sprintf('button[hx-get*="/book/desk/day/%s"]', $yesterday));
|
||||
|
||||
self::assertCount(1, $cell);
|
||||
self::assertSame('true', $cell->attr('aria-disabled'));
|
||||
self::assertStringContainsString('border-dashed', (string) $cell->attr('class'));
|
||||
}
|
||||
|
||||
public function testFutureDaysInTheCalendarAreNotMarkedAsPast(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$tomorrow = (new \DateTimeImmutable('today'))->modify('+1 day')->format('Y-m-d');
|
||||
|
||||
$crawler = $this->client->request('GET', '/book/desk');
|
||||
$cell = $crawler->filter(\sprintf('button[hx-get*="/book/desk/day/%s"]', $tomorrow));
|
||||
|
||||
self::assertCount(1, $cell);
|
||||
self::assertNull($cell->attr('aria-disabled'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -479,6 +572,30 @@ class BookingControllerTest extends WebTestCase
|
||||
return $this->client->getResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a confirmation modal and reads its CSRF token off the confirm button's hx-vals --
|
||||
* the same round trip a browser makes, rather than reaching into the token manager (which
|
||||
* has no session to read outside a request).
|
||||
*/
|
||||
private function confirmationToken(string $uri): string
|
||||
{
|
||||
$crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$vals = $crawler->filter('#htmx-confirmation-modal [hx-post]')->attr('hx-vals');
|
||||
|
||||
return json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
|
||||
}
|
||||
|
||||
private function admin(string $email, string $name = 'Chef'): User
|
||||
{
|
||||
$user = $this->user($email, $name);
|
||||
$user->setRoles(['ROLE_ADMIN']);
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function user(string $email, string $name = 'Test'): User
|
||||
{
|
||||
$user = new User($email, $name);
|
||||
|
||||
Reference in new issue
Block a user