Admins get /admin/history: one line per booking created, rescheduled or released, per series created or ended, and per resource deleted with its bookings. Sortable by time, kind, resource, booked day and both people; filterable by period, kinds, who acted and resource type. Events are snapshots written inside the same transaction as the change, so a rolled-back booking leaves none and a released booking stays readable. app:booking-events:prune keeps twelve months and needs a cron entry on the server. Co-Authored-By: Claude Opus 5.5 <[email protected]>
314 lines
13 KiB
PHP
314 lines
13 KiB
PHP
<?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());
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
}
|