Files
ephub/tests/Service/BookingManagerTest.php
T
AdminandClaude Opus 5.5 dcdce4d248 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]>
2026-10-16 16:02:07 +02:00

542 lines
21 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\Service;
use App\Entity\Booking;
use App\Entity\BookingEvent;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\ResourceTypeSettings;
use App\Entity\User;
use App\Enum\BookingEventType;
use App\Enum\RecurrenceRule;
use App\Enum\ResourceType;
use App\Exception\ResourceAlreadyBookedException;
use App\Model\TimeRange;
use App\Service\BookingEventRecorder;
use App\Service\BookingManager;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Bundle\FrameworkBundle\Test\MailerAssertionsTrait;
/**
* The double-booking guard now lives here rather than in a unique index, so this is where it is
* tested. BookingManager opens its own transactions (it locks the resource row FOR UPDATE), so
* these tests cannot wrap themselves in one -- a rollback would fight the manager's commits.
* They clean up explicitly instead.
*/
class BookingManagerTest extends KernelTestCase
{
use MailerAssertionsTrait;
private EntityManagerInterface $em;
private BookingManager $manager;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->manager = self::getContainer()->get(BookingManager::class);
$this->wipe();
}
protected function tearDown(): void
{
$this->wipe();
parent::tearDown();
}
public function testAFreeResourceIsBooked(): void
{
$booking = $this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
self::assertNotNull($booking->getId());
self::assertSame('09:00–13:00', (string) $booking->getTimeRange());
}
public function testAnOverlappingSpanOnTheSameResourceIsRefused(): void
{
$desk = $this->resource('D1');
$this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(540, 780));
$this->expectException(ResourceAlreadyBookedException::class);
$this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(660, 900));
}
public function testATouchingSpanOnTheSameResourceIsAllowed(): void
{
$desk = $this->resource('D1');
$this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(540, 780));
$second = $this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(780, 1020));
self::assertNotNull($second->getId());
}
/**
* This was a refusal once. It is allowed now -- the second desk is usually for the customer
* somebody is meeting -- and the overlap is reported as a flash instead, which
* BookingControllerTest covers.
*/
public function testOnePersonMayHoldTwoDesksAtOnce(): void
{
$user = $this->user('[email protected]');
$this->manager->book($user, $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
$second = $this->manager->book($user, $this->resource('D2'), $this->date(), TimeRange::fromMinutes(600, 720));
self::assertNotNull($second->getId());
}
public function testOnePersonMayHoldTwoDesksAtDifferentTimes(): void
{
$user = $this->user('[email protected]');
$this->manager->book($user, $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
$afternoon = $this->manager->book($user, $this->resource('D2'), $this->date(), TimeRange::fromMinutes(780, 1020));
self::assertNotNull($afternoon->getId());
}
public function testADeskAndAParkingSpotAtTheSameTimeAreBothAllowed(): void
{
$user = $this->user('[email protected]');
$time = TimeRange::fromMinutes(540, 780);
$this->manager->book($user, $this->resource('D1'), $this->date(), $time);
$parking = $this->manager->book($user, $this->resource('P1', ResourceType::PARKING), $this->date(), $time);
self::assertNotNull($parking->getId());
}
public function testASeriesMaterialisesOneBookingPerOccurrence(): void
{
// 2026-10-05 is a Monday; the Mondays up to the 30th are the 5th, 12th, 19th and 26th.
$series = $this->weeklySeries();
$result = $this->manager->bookSeries($series);
self::assertSame(4, $result->createdCount());
self::assertSame(0, $result->skippedCount());
foreach ($result->created as $booking) {
self::assertSame(1, (int) $booking->getDate()->format('N'));
self::assertSame($series, $booking->getSeries());
}
}
public function testATwoWeeklySeriesSkipsEveryOtherWeek(): void
{
$result = $this->manager->bookSeries(new BookingSeries(
$this->user('[email protected]'),
$this->resource('D1'),
RecurrenceRule::TWO_WEEKLY,
new \DateTimeImmutable('2026-10-05'),
new \DateTimeImmutable('2026-11-30'),
TimeRange::fromMinutes(540, 720),
));
self::assertSame(5, $result->createdCount());
self::assertSame(
['2026-10-05', '2026-10-19', '2026-11-02', '2026-11-16', '2026-11-30'],
array_map(static fn (Booking $booking): string => $booking->getDate()->format('Y-m-d'), $result->created),
);
}
/**
* The property the weekday field was removed in favour of: a stride that is a whole number of
* weeks lands on the same weekday every time, so nothing has to store which one it is. A
* four-weekly series is the one where this is easy to get wrong by reaching for months.
*/
public function testAFourWeeklySeriesAlwaysLandsOnTheSameWeekday(): void
{
$result = $this->manager->bookSeries(new BookingSeries(
$this->user('[email protected]'),
$this->resource('D1'),
RecurrenceRule::FOUR_WEEKLY,
new \DateTimeImmutable('2026-10-05'),
new \DateTimeImmutable('2026-12-31'),
TimeRange::fromMinutes(540, 720),
));
self::assertSame(4, $result->createdCount());
foreach ($result->created as $booking) {
self::assertSame(1, (int) $booking->getDate()->format('N'), 'every occurrence is a Monday');
}
// Crossing a month boundary is where a "monthly" reading would diverge: 28 days after
// 2 November is 30 November, not 2 December.
self::assertSame('2026-11-30', $result->created[2]->getDate()->format('Y-m-d'));
}
public function testADailySeriesCoversEveryDay(): void
{
$result = $this->manager->bookSeries(new BookingSeries(
$this->user('[email protected]'),
$this->resource('D1'),
RecurrenceRule::DAILY,
new \DateTimeImmutable('2026-10-05'),
new \DateTimeImmutable('2026-10-11'),
TimeRange::fromMinutes(540, 720),
));
self::assertSame(7, $result->createdCount());
}
/**
* The point of materialising: a taken day is reported rather than failing the whole series.
*/
public function testASeriesSkipsDaysThatAreAlreadyTaken(): void
{
$desk = $this->resource('D1');
// The 12th is one of the series' own Mondays.
$this->manager->book($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-12'), TimeRange::fromMinutes(600, 660));
$result = $this->manager->bookSeries(new BookingSeries(
$this->user('[email protected]'),
$desk,
RecurrenceRule::WEEKLY,
new \DateTimeImmutable('2026-10-05'),
new \DateTimeImmutable('2026-10-30'),
TimeRange::fromMinutes(540, 720),
));
self::assertSame(3, $result->createdCount());
self::assertSame(['12.10.'], $result->skippedDates());
self::assertFalse($result->isCompletelyBlocked());
}
public function testASeriesThatCollidesEverywhereReportsItself(): void
{
$desk = $this->resource('D1');
$this->manager->book($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-05'));
$result = $this->manager->bookSeries(new BookingSeries(
$this->user('[email protected]'),
$desk,
RecurrenceRule::WEEKLY,
new \DateTimeImmutable('2026-10-05'),
new \DateTimeImmutable('2026-10-09'),
TimeRange::fromMinutes(540, 720),
[1],
));
self::assertTrue($result->isCompletelyBlocked());
self::assertSame(0, $result->createdCount());
}
public function testReschedulingAnOccurrenceMarksItAsAnOverride(): void
{
$series = $this->weeklySeries();
$occurrence = $this->manager->bookSeries($series)->created[0];
$this->manager->reschedule($occurrence, TimeRange::fromMinutes(840, 960));
self::assertTrue($occurrence->isOverridden());
self::assertSame('14:00–16:00', (string) $occurrence->getTimeRange());
}
public function testReschedulingBackOntoTheSeriesTimesClearsTheOverride(): void
{
$series = $this->weeklySeries();
$occurrence = $this->manager->bookSeries($series)->created[0];
$this->manager->reschedule($occurrence, TimeRange::fromMinutes(840, 960));
$this->manager->reschedule($occurrence, $series->getTimeRange());
self::assertFalse($occurrence->isOverridden());
}
public function testReschedulingIntoSomebodyElsesSpanIsRefused(): void
{
$desk = $this->resource('D1');
$mine = $this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(540, 780));
$this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(780, 1020));
$this->expectException(ResourceAlreadyBookedException::class);
$this->manager->reschedule($mine, TimeRange::fromMinutes(540, 900));
}
/**
* Rescheduling must not trip over the booking it is moving -- that would make every edit
* that keeps any part of its own span impossible.
*/
public function testAnOccurrenceCanBeShrunkWithoutCollidingWithItself(): void
{
$booking = $this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
$this->manager->reschedule($booking, TimeRange::fromMinutes(600, 720));
self::assertSame('10:00–12:00', (string) $booking->getTimeRange());
}
public function testEndingASeriesRemovesItsRemainingOccurrences(): void
{
$series = $this->weeklySeries();
$this->manager->bookSeries($series);
$id = $series->getId();
$removed = $this->manager->endSeries($series, new \DateTimeImmutable('2026-10-05'));
self::assertSame(4, $removed);
self::assertSame([], $this->em->getRepository(Booking::class)->findBy(['series' => $id]));
// Nothing of the series is left, so the series row goes too -- note $series has no id
// any more at this point, which is why the lookup goes through the captured one.
self::assertNull($this->em->getRepository(BookingSeries::class)->find($id));
}
public function testEndingASeriesPartWayThroughKeepsThePastAndShortensIt(): void
{
$series = $this->weeklySeries();
$this->manager->bookSeries($series);
// Leaves the Mondays on the 5th and 12th, removes the 19th and 26th.
$removed = $this->manager->endSeries($series, new \DateTimeImmutable('2026-10-15'));
self::assertSame(2, $removed);
self::assertCount(2, $this->em->getRepository(Booking::class)->findBy(['series' => $series]));
self::assertSame('2026-10-14', $series->getEndDate()->format('Y-m-d'));
}
public function testABookingNotifiesTheRecipientsOfItsType(): void
{
$this->recipientsFor(ResourceType::DESK, ['[email protected]', '[email protected]']);
$this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
// One mail each, addressed to them alone: no recipient learns who else is on the list.
self::assertEmailCount(2);
$emails = self::getMailerMessages();
self::assertSame(
['[email protected]', '[email protected]'],
array_map(static fn ($email): string => $email->getTo()[0]->getAddress(), $emails),
);
foreach ($emails as $email) {
self::assertCount(1, $email->getTo());
self::assertSame([], $email->getBcc());
self::assertSame('Neue Buchung: D1 am 01.10.2026', $email->getSubject());
self::assertStringContainsString('D1', $email->getTextBody());
// Both parts render: the MyE&P layout, and the text fallback beside it.
self::assertStringContainsString('D1', $email->getHtmlBody());
self::assertStringContainsString('Neue Buchung', $email->getHtmlBody());
// The logo resolved through the @images Twig path and was embedded, not hotlinked.
self::assertStringContainsString('cid:', $email->getHtmlBody());
}
}
public function testAKindWithNoRecipientsNotifiesNobody(): void
{
$this->recipientsFor(ResourceType::PARKING, ['[email protected]']);
$this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date());
self::assertEmailCount(0);
}
/**
* One mail per recipient for the series rather than one per occurrence -- four Mondays and two
* recipients make two mails, and a yearly series would otherwise be a hundred and four.
*/
public function testASeriesSendsOneMailPerRecipientNotPerOccurrence(): void
{
$this->recipientsFor(ResourceType::DESK, ['[email protected]', '[email protected]']);
$result = $this->manager->bookSeries($this->weeklySeries());
self::assertSame(4, $result->createdCount());
self::assertEmailCount(2);
self::assertStringContainsString('4', (string) self::getMailerMessage()?->getTextBody());
}
public function testASeriesThatBookedNothingNotifiesNobody(): void
{
$this->recipientsFor(ResourceType::DESK, ['[email protected]']);
$desk = $this->resource('D1');
$this->manager->book($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-05'));
$result = $this->manager->bookSeries(new BookingSeries(
$this->user('[email protected]'),
$desk,
RecurrenceRule::WEEKLY,
new \DateTimeImmutable('2026-10-05'),
new \DateTimeImmutable('2026-10-09'),
TimeRange::fromMinutes(540, 720),
));
self::assertTrue($result->isCompletelyBlocked());
// The one booking above is the only mail-worthy event, and it happened before recipients
// could matter for the series -- so exactly one mail, for that booking.
self::assertEmailCount(1);
}
public function testABookingRecordsAnEvent(): void
{
$booking = $this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
$event = $this->onlyEvent();
self::assertSame(BookingEventType::BOOKING_CREATED, $event->getType());
self::assertSame($booking->getId(), $event->getBookingId());
self::assertSame('D1', $event->getResourceLabel());
self::assertSame('Test', $event->getHolderName());
self::assertSame('2026-10-01', $event->getDate()?->format('Y-m-d'));
self::assertSame('09:00–13:00', (string) $event->getTimeRange());
// No token in a kernel test, as for a console command.
self::assertNull($event->getActor());
self::assertSame(BookingEventRecorder::SYSTEM, $event->getActorName());
}
/**
* The event is written in the booking's transaction, so a refusal leaves nothing behind.
*/
public function testARefusedBookingRecordsNoEvent(): void
{
$desk = $this->resource('D1');
$this->manager->book($this->user('[email protected]'), $desk, $this->date());
try {
$this->manager->book($this->user('[email protected]'), $desk, $this->date());
self::fail('The second booking should have been refused.');
} catch (ResourceAlreadyBookedException) {
}
self::assertSame(1, $this->em->getRepository(BookingEvent::class)->count([]));
}
public function testReschedulingRecordsTheOldAndTheNewTimes(): void
{
$booking = $this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
$this->manager->reschedule($booking, TimeRange::fromMinutes(600, 720));
$event = $this->latestEvent();
self::assertSame(BookingEventType::BOOKING_RESCHEDULED, $event->getType());
self::assertSame('09:00–13:00', (string) $event->getPreviousTimeRange());
self::assertSame('10:00–12:00', (string) $event->getTimeRange());
}
public function testReleasingRecordsTheBookingThatWent(): void
{
$booking = $this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date());
$id = $booking->getId();
$this->manager->release($booking);
$event = $this->latestEvent();
self::assertSame(BookingEventType::BOOKING_RELEASED, $event->getType());
self::assertSame($id, $event->getBookingId());
self::assertSame('D1', $event->getResourceLabel());
}
/**
* One event for the series, not one per day -- the same rule as the audit log.
*/
public function testASeriesRecordsOneEvent(): void
{
$series = $this->weeklySeries();
$this->manager->bookSeries($series);
$event = $this->onlyEvent();
self::assertSame(BookingEventType::SERIES_CREATED, $event->getType());
self::assertSame($series->getId(), $event->getSeriesId());
self::assertSame(RecurrenceRule::WEEKLY, $event->getRule());
self::assertSame(4, $event->getDetails()['created']);
self::assertSame('2026-10-30', $event->getEndDate()?->format('Y-m-d'));
}
/**
* Ending a series that has nothing left deletes it, so the id has to survive from before.
*/
public function testEndingASeriesRecordsItsIdEvenWhenTheSeriesIsGone(): void
{
$series = $this->weeklySeries();
$this->manager->bookSeries($series);
$id = $series->getId();
$this->manager->endSeries($series, new \DateTimeImmutable('2026-10-05'));
$event = $this->latestEvent();
self::assertSame(BookingEventType::SERIES_ENDED, $event->getType());
self::assertSame($id, $event->getSeriesId());
self::assertSame(4, $event->getDetails()['removed']);
self::assertSame('2026-10-05', $event->getDate()?->format('Y-m-d'));
}
/**
* @param list<string> $recipients
*/
private function recipientsFor(ResourceType $type, array $recipients): void
{
$settings = new ResourceTypeSettings($type);
$settings->setNotificationRecipients($recipients);
$this->em->persist($settings);
$this->em->flush();
}
private function weeklySeries(): BookingSeries
{
return new BookingSeries(
$this->user('[email protected]'),
$this->resource('D1'),
RecurrenceRule::WEEKLY,
new \DateTimeImmutable('2026-10-05'),
new \DateTimeImmutable('2026-10-30'),
TimeRange::fromMinutes(540, 720),
);
}
private function date(): \DateTimeImmutable
{
return new \DateTimeImmutable('2026-10-01');
}
private function user(string $email): User
{
$user = new User($email, 'Test');
$user->setRoles(['ROLE_EMPLOYEE']);
$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;
}
private function onlyEvent(): BookingEvent
{
$events = $this->em->getRepository(BookingEvent::class)->findAll();
self::assertCount(1, $events);
return $events[0];
}
private function latestEvent(): BookingEvent
{
$event = $this->em->getRepository(BookingEvent::class)->findOneBy([], ['id' => 'DESC']);
self::assertInstanceOf(BookingEvent::class, $event);
return $event;
}
private function wipe(): void
{
$connection = $this->em->getConnection();
$connection->executeStatement('DELETE FROM booking_event');
$connection->executeStatement('DELETE FROM booking');
$connection->executeStatement('DELETE FROM booking_series');
$connection->executeStatement('DELETE FROM resource');
$connection->executeStatement('DELETE FROM resource_type_settings');
$connection->executeStatement('DELETE FROM `user`');
$this->em->clear();
}
}