feat: add admin views, a booking horizon and confirmation modals

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
frommeandClaude Opus 5 committed 2026-09-14 09:40:17 +02:00
1 parent 8caf618950
commit 1a3640b15c
27 files changed
+1269 -62

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Tests\Booking;
use App\Booking\Horizon;
use PHPUnit\Framework\TestCase;
class HorizonTest extends TestCase
{
public function testTheHorizonIsThreeMonthsFromTheGivenDay(): void
{
self::assertSame(
'2026-12-14',
Horizon::latest(new \DateTimeImmutable('2026-09-14'))->format('Y-m-d'),
);
}
/**
* PHP's "+3 months" from a 31st overflows into the next month (31 November does not exist,
* so it becomes 1 December). Pinned rather than clamped: this is a cap, and one extra day of
* horizon four times a year is not worth the arithmetic it would take to remove. The
* recurrence code deliberately avoids months for exactly this reason -- see
* RecurrenceRule::intervalDays() -- but a single boundary can live with it.
*/
public function testAMonthEndOverflowsByADayAndThatIsAccepted(): void
{
self::assertSame(
'2026-12-01',
Horizon::latest(new \DateTimeImmutable('2026-08-31'))->format('Y-m-d'),
);
}
public function testTheTimePartIsDroppedSoTheComparisonIsPerDay(): void
{
self::assertSame(
'00:00:00',
Horizon::latest(new \DateTimeImmutable('2026-09-14 17:42:13'))->format('H:i:s'),
);
}
public function testItDefaultsToToday(): void
{
self::assertEquals(
Horizon::latest(new \DateTimeImmutable('today')),
Horizon::latest(),
);
}
}
@@ -0,0 +1,287 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* The admin area, and the widened ownership checks it leans on.
*
* The test that matters most here is the negative one: releasing somebody else's booking must
* still be refused for an ordinary employee. Everything else on this screen is a list.
*/
class BookingAdminControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $em;
protected function setUp(): void
{
$this->client = self::createClient();
$this->client->disableReboot();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->em->getConnection()->beginTransaction();
}
protected function tearDown(): void
{
$this->em->getConnection()->rollBack();
parent::tearDown();
}
public function testAnEmployeeCannotReachTheAdminList(): void
{
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/admin/bookings');
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
}
public function testTheAdminListShowsOtherPeoplesBookings(): void
{
$this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings');
self::assertResponseIsSuccessful();
self::assertStringContainsString('Bea Berger', $crawler->filter('body')->text());
}
public function testAnAdminReleasesABookingOnBehalfOfSomebodyElse(): void
{
$booking = $this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
$id = $booking->getId();
$this->client->loginUser($this->admin());
// The two-step flow a browser walks: the list opens the confirmation modal, the modal
// carries the token and posts back to the same URI.
$crawler = $this->client->request('GET', '/admin/bookings');
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/release?return=admin"]', $id)));
$uri = \sprintf('/bookings/%d/release?return=admin', $id);
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
// ?return= means a genuine navigation rather than a board swap: this page has no board
// fragments, so HX-Redirect is the honest answer.
self::assertSame('/admin/bookings', $this->client->getResponse()->headers->get('HX-Redirect'));
$this->em->clear();
self::assertNull($this->em->getRepository(Booking::class)->find($id));
}
/**
* The regression that matters: widening the check for admins must not have widened it for
* everybody.
*/
public function testAnEmployeeStillCannotReleaseSomebodyElsesBooking(): void
{
$booking = $this->booking($this->user('[email protected]'), $this->resource('D1'));
$id = $booking->getId();
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('POST', \sprintf('/bookings/%d/release', $id), ['_token' => 'whatever']);
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
// The ownership guard runs before the CSRF one, so asserting on its message is what
// distinguishes "refused because it is not yours" from "refused because of the token".
self::assertStringContainsString(
'released by the person holding it or by an administrator',
(string) $this->client->getResponse()->getContent(),
);
self::assertNotNull($this->em->getRepository(Booking::class)->find($id));
}
/**
* Admins release; they do not reschedule. Moving somebody else's booking is a different act
* and was deliberately left alone.
*/
public function testAnAdminStillCannotEditSomebodyElsesBooking(): void
{
$booking = $this->booking($this->user('[email protected]'), $this->resource('D1'));
$this->client->loginUser($this->admin());
$this->client->request('GET', \sprintf('/bookings/%d/edit', $booking->getId()));
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
}
public function testTheListNarrowsToOnePerson(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$this->booking($bea, $this->resource('D1'));
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2'));
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId());
self::assertResponseIsSuccessful();
$text = $crawler->filter('ul')->text();
self::assertStringContainsString('Bea Berger', $text);
self::assertStringNotContainsString('Carl Conrad', $text);
}
public function testTheListNarrowsToOneResourceType(): void
{
$this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('P1', ResourceType::PARKING));
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings?type=parking');
self::assertResponseIsSuccessful();
$text = $crawler->filter('ul')->text();
self::assertStringContainsString('Carl Conrad', $text);
self::assertStringNotContainsString('Bea Berger', $text);
}
/**
* The way into the admin modal from the board: a desk held by a colleague is inert for an
* employee and clickable for an admin.
*/
public function testAnOccupiedDeskIsClickableForAnAdminOnly(): void
{
$desk = $this->resource('D1', ResourceType::DESK, 'desk-01');
$this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
$date = (new \DateTimeImmutable('today'))->format('Y-m-d');
$this->client->loginUser($this->user('[email protected]'));
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
self::assertCount(0, $crawler->filter('g[hx-get*="/admin/spot/"]'));
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
self::assertCount(1, $crawler->filter(\sprintf('g[hx-get="/admin/spot/%d/%s"]', $desk->getId(), $date)));
}
public function testTheSpotModalListsTheHolderAndReleasesTheBooking(): void
{
$desk = $this->resource('D1', ResourceType::DESK, 'desk-01');
$booking = $this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
$this->client->loginUser($this->admin());
$crawler = $this->client->request(
'GET',
\sprintf('/admin/spot/%d/%s', $desk->getId(), $booking->getDate()->format('Y-m-d')),
server: ['HTTP_HX-Request' => 'true'],
);
self::assertResponseIsSuccessful();
self::assertStringContainsString('Bea Berger', $crawler->filter('#htmx-modal')->text());
self::assertCount(1, $crawler->filter(\sprintf('button[hx-get="/bookings/%d/release"]', $booking->getId())));
}
/**
* The modal is scoped to the shape that was clicked, not to the whole day.
*/
public function testTheSpotModalShowsOnlyThatResourcesBookings(): void
{
$desk = $this->resource('D1', ResourceType::DESK, 'desk-01');
$this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2', ResourceType::DESK, 'desk-02'));
$this->client->loginUser($this->admin());
$crawler = $this->client->request(
'GET',
\sprintf('/admin/spot/%d/%s', $desk->getId(), (new \DateTimeImmutable('today'))->format('Y-m-d')),
server: ['HTTP_HX-Request' => 'true'],
);
$text = $crawler->filter('#htmx-modal')->text();
self::assertStringContainsString('Bea Berger', $text);
self::assertStringNotContainsString('Carl Conrad', $text);
}
public function testTheDashboardTileIsAdminOnly(): void
{
$this->client->loginUser($this->user('[email protected]'));
self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/admin/bookings"]'));
$this->client->loginUser($this->admin());
self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/admin/bookings"]'));
}
/**
* The confirmation modal names the holder, so an administrator cannot free the wrong desk
* without seeing whose it is. This is the whole reason it exists rather than a
* window.confirm.
*/
public function testTheConfirmationModalNamesTheHolder(): void
{
$booking = $this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
$this->client->loginUser($this->admin());
$crawler = $this->client->request(
'GET',
\sprintf('/bookings/%d/release?return=admin', $booking->getId()),
server: ['HTTP_HX-Request' => 'true'],
);
self::assertResponseIsSuccessful();
$modal = $crawler->filter('#htmx-confirmation-modal');
self::assertStringContainsString('Bea Berger', $modal->text());
// Posts back to the same URI, which is what carries ?return=admin through the round trip.
self::assertSame(
\sprintf('http://localhost/bookings/%d/release?return=admin', $booking->getId()),
$modal->filter('[hx-post]')->attr('hx-post'),
);
}
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 booking(User $user, Resource $resource): Booking
{
$booking = new Booking($user, $resource, new \DateTimeImmutable('today'));
$this->em->persist($booking);
$this->em->flush();
return $booking;
}
private function admin(string $email = '[email protected]', string $name = 'Chef Chefin'): User
{
return $this->user($email, $name, ['ROLE_ADMIN']);
}
/**
* @param list<string> $roles
*/
private function user(string $email, string $name = 'Test', array $roles = ['ROLE_EMPLOYEE']): User
{
$user = new User($email, $name);
$user->setRoles($roles);
$this->em->persist($user);
$this->em->flush();
return $user;
}
/**
* The floor plan only draws resources whose svgElementId appears in the geometry map.
*/
private function resource(string $label, ResourceType $type = ResourceType::DESK, ?string $svgElementId = null): Resource
{
$resource = new Resource($type, $label, $svgElementId);
$this->em->persist($resource);
$this->em->flush();
return $resource;
}
}
+126 -9
View File
@@ -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);