feat: add a booking event history for admins
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]>
This commit is contained in:
1 parent
d68b6b156c
commit
dcdce4d248
18 files changed
+1556
-6
No files matched your search
@@ -0,0 +1,313 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Controller\Admin;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\BookingEvent;
|
||||
use App\Entity\BookingSeries;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceGroup;
|
||||
use App\Entity\ResourceTypeSettings;
|
||||
use App\Entity\User;
|
||||
use App\Enum\BookingEventType;
|
||||
use App\Enum\RecurrenceRule;
|
||||
use App\Enum\ResourceType;
|
||||
use App\Model\TimeRange;
|
||||
@@ -268,6 +270,29 @@ class ResourceAdminControllerTest extends WebTestCase
|
||||
$remaining = $this->em->getRepository(Booking::class)->findAll();
|
||||
self::assertCount(1, $remaining);
|
||||
self::assertSame($other->getId(), $remaining[0]->getResource()->getId());
|
||||
|
||||
// The bookings went without passing through BookingManager, so the history gets one line
|
||||
// for the lot here.
|
||||
$events = $this->em->getRepository(BookingEvent::class)->findAll();
|
||||
self::assertCount(1, $events);
|
||||
self::assertSame(BookingEventType::RESOURCE_DELETED, $events[0]->getType());
|
||||
self::assertSame('Schreibtisch 01', $events[0]->getResourceLabel());
|
||||
self::assertSame(3, $events[0]->getDetails()['removed']);
|
||||
}
|
||||
|
||||
/**
|
||||
* An unused resource is configuration, not booking history.
|
||||
*/
|
||||
public function testDeletingAResourceWithoutBookingsRecordsNoEvent(): void
|
||||
{
|
||||
$desk = $this->resource('Schreibtisch 01');
|
||||
$this->client->loginUser($this->admin());
|
||||
$uri = \sprintf('/admin/resources/%d/delete', $desk->getId());
|
||||
|
||||
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
self::assertSame('/admin/resources', $this->client->getResponse()->headers->get('HX-Redirect'));
|
||||
self::assertSame(0, $this->em->getRepository(BookingEvent::class)->count([]));
|
||||
}
|
||||
|
||||
public function testDeletingAResourceNeedsAValidToken(): void
|
||||
|
||||
Reference in new issue
Block a user