Files
ephub/tests/Controller/Admin/BookingAdminControllerTest.php
T

329 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\User;
use App\Enum\ResourceType;
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(server: ['HTTPS' => true]);
$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('tbody')->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('tbody')->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');
$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('[data-spot][hx-get*="/admin/spot/"]'));
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
self::assertCount(1, $crawler->filter(\sprintf('[data-spot][hx-get="/admin/spot/%d/%s"]', $desk->getId(), $date)));
}
public function testTheSpotModalListsTheHolderAndReleasesTheBooking(): void
{
$desk = $this->resource('D1');
$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');
$this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2'));
$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('https://localhost/bookings/%d/release?return=admin', $booking->getId()),
$modal->filter('[hx-post]')->attr('hx-post'),
);
}
/**
* The list is paginated, so the window no longer decides how much gets rendered. Page two is
* the part of the assertion that matters: the first page could be right by accident if the
* limit were applied without an offset.
*/
public function testTheListIsPaginated(): void
{
$user = $this->user('[email protected]', 'Bea Berger');
$desk = $this->resource('D1');
for ($day = 0; $day < 30; ++$day) {
$this->booking($user, $desk, new \DateTimeImmutable('today +'.$day.' days'));
}
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/bookings');
self::assertResponseIsSuccessful();
self::assertCount(25, $crawler->filter('tbody tr'));
$crawler = $this->client->request('GET', '/admin/bookings?page=2');
self::assertResponseIsSuccessful();
self::assertCount(5, $crawler->filter('tbody tr'));
}
/**
* Sorting has to survive next to the filter: both live in the query string, and a sort link
* that dropped the filter would silently widen what is being paged through.
*/
public function testAColumnHeaderSortsWithoutLosingTheFilter(): 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().'&sort=u.name&direction=desc');
self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text();
self::assertStringContainsString('Bea Berger', $text);
self::assertStringNotContainsString('Carl Conrad', $text);
}
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, ?\DateTimeImmutable $date = null): Booking
{
$booking = new Booking($user, $resource, $date ?? 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;
}
private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource
{
$resource = new Resource($type, $label);
$this->em->persist($resource);
$this->em->flush();
return $resource;
}
}