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

332 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin;
use App\Entity\Booking;
use App\Entity\BookingEvent;
use App\Entity\Resource;
use App\Entity\User;
use App\Enum\BookingEventType;
use App\Enum\ResourceType;
use App\Model\TimeRange;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* The event history at /admin/history: who may see it, what it shows, and that its filters and
* sort links narrow what they say they do. What gets *recorded* is tested in BookingManagerTest;
* the one end-to-end test here goes through a real release, because "acting for somebody else"
* is only meaningful with a signed-in actor.
*/
class BookingEventControllerTest 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();
}
/**
* Supervisors free bookings themselves, but the history across the whole staff is the
* administrators' alone.
*/
public function testOnlyAnAdminCanReachTheHistory(): void
{
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('GET', '/admin/history');
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
$this->client->loginUser($this->user('[email protected]', 'Sam Supervisor', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']));
$this->client->request('GET', '/admin/history');
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
$this->client->loginUser($this->admin());
$this->client->request('GET', '/admin/history');
self::assertResponseIsSuccessful();
}
public function testTheDashboardTileIsForAdminsOnly(): void
{
$this->client->loginUser($this->user('[email protected]', 'Sam Supervisor', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']));
self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/admin/history"]'));
$this->client->loginUser($this->admin());
self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/admin/history"]'));
}
public function testAReleaseOnSomebodysBehalfShowsBothPeople(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$booking = new Booking($bea, $this->resource('Tisch 12'), new \DateTimeImmutable('today'));
$this->em->persist($booking);
$this->em->flush();
$this->client->loginUser($this->admin());
$uri = \sprintf('/bookings/%d/release', $booking->getId());
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
$crawler = $this->client->request('GET', '/admin/history');
self::assertResponseIsSuccessful();
$row = $crawler->filter('tbody tr')->first()->text();
self::assertStringContainsString('Buchung freigegeben', $row);
self::assertStringContainsString('Tisch 12', $row);
self::assertStringContainsString('Bea Berger', $row);
self::assertStringContainsString('Chef Chefin', $row);
self::assertStringContainsString('stellvertretend', $row);
}
public function testSomebodyActingOnTheirOwnBookingIsNotMarked(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea);
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/history');
self::assertStringNotContainsString('stellvertretend', $crawler->filter('tbody')->text());
}
/**
* A console command or the worker has no booking of its own, so it acts for nobody.
*/
public function testTheSystemActingIsNotMarked(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$event = (new BookingEvent(BookingEventType::BOOKING_RELEASED, null, BookingEvent::SYSTEM_ACTOR, null, 'Tisch 1', ResourceType::DESK))
->setHolder($bea);
$this->em->persist($event);
$this->em->flush();
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/history');
self::assertStringContainsString('Bea Berger', $crawler->filter('tbody')->text());
self::assertStringNotContainsString('stellvertretend', $crawler->filter('tbody')->text());
}
/**
* The person filter is about who carried the change out: Carl's booking freed by Bea is
* Bea's line, not Carl's.
*/
public function testThePersonFilterMatchesWhoActed(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$carl = $this->user('[email protected]', 'Carl Conrad');
$this->event(BookingEventType::BOOKING_RELEASED, $bea, $carl, 'Tisch 1');
$this->event(BookingEventType::BOOKING_CREATED, $carl, $carl, 'Tisch 2');
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/history?actor='.$bea->getId());
self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text();
self::assertStringContainsString('Tisch 1', $text);
self::assertStringNotContainsString('Tisch 2', $text);
}
public function testTheTypeFilterAcceptsSeveralKinds(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1');
$this->event(BookingEventType::BOOKING_RESCHEDULED, $bea, $bea, 'Tisch 2');
$this->event(BookingEventType::BOOKING_RELEASED, $bea, $bea, 'Tisch 3');
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/history?types[]=booking_created&types[]=booking_released');
self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text();
self::assertStringContainsString('Tisch 1', $text);
self::assertStringNotContainsString('Tisch 2', $text);
self::assertStringContainsString('Tisch 3', $text);
}
public function testTheResourceTypeFilter(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1');
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'P1', ResourceType::PARKING);
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/history?resourceType=parking');
$text = $crawler->filter('tbody')->text();
self::assertStringContainsString('P1', $text);
self::assertStringNotContainsString('Tisch 1', $text);
}
/**
* The window is about when it happened. The default reaches back thirty days, and the "to"
* day is included in full, not cut off at its midnight.
*/
public function testTheDateWindowIsAboutWhenItHappened(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch alt'), '-40 days');
$this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch gestern'), 'yesterday 23:30');
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch heute');
$this->em->clear();
$this->client->loginUser($this->admin());
$text = $this->client->request('GET', '/admin/history')->filter('tbody')->text();
self::assertStringNotContainsString('Tisch alt', $text);
self::assertStringContainsString('Tisch gestern', $text);
self::assertStringContainsString('Tisch heute', $text);
$yesterday = (new \DateTimeImmutable('yesterday'))->format('Y-m-d');
$text = $this->client->request('GET', '/admin/history?from='.$yesterday.'&to='.$yesterday)->filter('tbody')->text();
self::assertStringContainsString('Tisch gestern', $text);
self::assertStringNotContainsString('Tisch heute', $text);
}
public function testTheNewestEventComesFirst(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch früher'), '-2 hours');
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch später');
$this->em->clear();
$this->client->loginUser($this->admin());
$rows = $this->client->request('GET', '/admin/history')->filter('tbody tr');
self::assertStringContainsString('Tisch später', $rows->eq(0)->text());
self::assertStringContainsString('Tisch früher', $rows->eq(1)->text());
$rows = $this->client->request('GET', '/admin/history?sort=e.occurredAt&direction=asc')->filter('tbody tr');
self::assertStringContainsString('Tisch früher', $rows->eq(0)->text());
}
public function testSortingByWhoActedKeepsTheFilter(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$carl = $this->user('[email protected]', 'Carl Conrad');
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1');
$this->event(BookingEventType::BOOKING_CREATED, $carl, $carl, 'Tisch 2');
$this->event(BookingEventType::BOOKING_RELEASED, $carl, $carl, 'Tisch 3');
$this->client->loginUser($this->admin());
$rows = $this->client
->request('GET', '/admin/history?types[]=booking_created&sort=e.actorName&direction=desc')
->filter('tbody tr');
self::assertCount(2, $rows);
self::assertStringContainsString('Carl Conrad', $rows->eq(0)->text());
self::assertStringContainsString('Bea Berger', $rows->eq(1)->text());
}
/**
* Every kind has its own branch in the "Buchung" column; each is rendered once here so a typo
* in one of them cannot hide until somebody filters for it.
*/
public function testEveryKindOfEventRenders(): void
{
$bea = $this->user('[email protected]', 'Bea Berger');
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea);
$this->event(BookingEventType::BOOKING_RESCHEDULED, $bea, $bea)
->setTimeRange(TimeRange::fromMinutes(600, 720), TimeRange::fromMinutes(540, 780));
$this->event(BookingEventType::BOOKING_RELEASED, $bea, $bea);
$this->event(BookingEventType::SERIES_CREATED, $bea, $bea)
->setDates(new \DateTimeImmutable('today'), new \DateTimeImmutable('+4 weeks'))
->setDetails(['rule' => 'weekly', 'created' => 4, 'skipped' => ['2026-10-20']]);
$this->event(BookingEventType::SERIES_ENDED, $bea, $bea)
->setDetails(['rule' => 'weekly', 'removed' => 3]);
$deleted = new BookingEvent(BookingEventType::RESOURCE_DELETED, $bea, 'Bea Berger', 7, 'Tisch 9', ResourceType::DESK);
$this->em->persist($deleted->setDetails(['removed' => 5]));
$this->em->flush();
$this->client->loginUser($this->admin());
$crawler = $this->client->request('GET', '/admin/history');
self::assertResponseIsSuccessful();
$text = $crawler->filter('tbody')->text();
self::assertStringContainsString('09:00–13:00 → 10:00–12:00', $text);
self::assertStringContainsString('Wöchentlich', $text);
self::assertStringContainsString('4 Termine, 1 übersprungen', $text);
self::assertStringContainsString('3 Termine entfernt', $text);
self::assertStringContainsString('5 Buchungen gelöscht', $text);
self::assertStringContainsString('6 Ereignisse', $crawler->filter('body')->text());
}
private function event(
BookingEventType $type,
User $actor,
User $holder,
string $label = 'Tisch 1',
ResourceType $resourceType = ResourceType::DESK,
): BookingEvent {
$event = (new BookingEvent($type, $actor, $actor->getName(), null, $label, $resourceType))
->setHolder($holder)
->setDates(new \DateTimeImmutable('today'))
->setTimeRange(TimeRange::wholeDay());
$this->em->persist($event);
$this->em->flush();
return $event;
}
/**
* The constructor stamps the current time and nothing should be able to change it afterwards,
* so a test that needs an older event rewrites the row. Call clear() once the setup is done,
* or the page renders the stale time still held in the identity map.
*/
private function occurredAt(BookingEvent $event, string $when): void
{
$this->em->getConnection()->executeStatement(
'UPDATE booking_event SET occurred_at = ? WHERE id = ?',
[(new \DateTimeImmutable($when))->format('Y-m-d H:i:s'), $event->getId()],
);
}
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(): User
{
return $this->user('[email protected]', 'Chef Chefin', ['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): Resource
{
$resource = new Resource(ResourceType::DESK, $label);
$this->em->persist($resource);
$this->em->flush();
return $resource;
}
}