208 lines
7.2 KiB
PHP
208 lines
7.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Service;
|
|
|
|
use App\Entity\AccessCode;
|
|
use App\Entity\Booking;
|
|
use App\Entity\BookingSeries;
|
|
use App\Entity\Resource;
|
|
use App\Entity\ResourceGroup;
|
|
use App\Entity\User;
|
|
use App\Enum\CodeVisibility;
|
|
use App\Enum\RecurrenceRule;
|
|
use App\Enum\ResourceType;
|
|
use App\Model\SeriesResult;
|
|
use App\Model\TimeRange;
|
|
use App\Service\AuditLogger;
|
|
use Monolog\Handler\TestHandler;
|
|
use Monolog\LogRecord;
|
|
use Monolog\Logger;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* What the trail has to say, rather than that it says something: an audit record is only useful
|
|
* if it names the holder and the resource, since the actor beside it is what makes an
|
|
* administrator acting for somebody else visible at all.
|
|
*/
|
|
class AuditLoggerTest extends TestCase
|
|
{
|
|
private TestHandler $handler;
|
|
private AuditLogger $audit;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->handler = new TestHandler();
|
|
$this->audit = new AuditLogger(new Logger('audit', [$this->handler]));
|
|
}
|
|
|
|
public function testACreatedBookingIsRecordedWithItsHolderAndResource(): void
|
|
{
|
|
$this->audit->bookingCreated($this->booking());
|
|
|
|
$record = $this->record();
|
|
|
|
self::assertSame('Booking created.', $record->message);
|
|
self::assertSame('[email protected]', $record->context['holder']);
|
|
self::assertSame('D1', $record->context['resource']);
|
|
self::assertSame('2026-09-18', $record->context['date']);
|
|
self::assertSame('09:00-13:00', $record->context['time']);
|
|
}
|
|
|
|
public function testARescheduledBookingKeepsTheSpanItCameFrom(): void
|
|
{
|
|
$booking = $this->booking();
|
|
$previous = $booking->getTimeRange();
|
|
$booking->changeTime(TimeRange::fromMinutes(600, 900));
|
|
|
|
$this->audit->bookingRescheduled($booking, $previous);
|
|
|
|
$record = $this->record();
|
|
|
|
self::assertSame('Booking rescheduled.', $record->message);
|
|
self::assertSame('09:00-13:00', $record->context['previous_time']);
|
|
self::assertSame('10:00-15:00', $record->context['time']);
|
|
}
|
|
|
|
/**
|
|
* A series is one record, not one per occurrence -- otherwise a year of weekly bookings
|
|
* buries every other event in the file.
|
|
*/
|
|
public function testASeriesIsRecordedOnceWithTheDaysItCouldNotHave(): void
|
|
{
|
|
$series = new BookingSeries(
|
|
$this->user(),
|
|
$this->resource(),
|
|
RecurrenceRule::WEEKLY,
|
|
new \DateTimeImmutable('2026-09-18'),
|
|
new \DateTimeImmutable('2026-10-02'),
|
|
TimeRange::fromMinutes(540, 780),
|
|
);
|
|
|
|
$this->audit->seriesCreated(new SeriesResult($series, [$this->booking()], ['2026-09-25' => 'taken']));
|
|
|
|
$records = $this->handler->getRecords();
|
|
|
|
self::assertCount(1, $records);
|
|
self::assertSame('Booking series created.', $records[0]->message);
|
|
self::assertSame(1, $records[0]->context['bookings_created']);
|
|
self::assertSame(['2026-09-25'], $records[0]->context['skipped_dates']);
|
|
}
|
|
|
|
/**
|
|
* Ending a series can delete it, and Doctrine nulls the id of a deleted entity -- so the id
|
|
* is passed in, and the record must use the one it was given.
|
|
*/
|
|
public function testAnEndedSeriesIsRecordedWithTheIdItHadBeforeItWasDeleted(): void
|
|
{
|
|
$series = new BookingSeries(
|
|
$this->user(),
|
|
$this->resource(),
|
|
RecurrenceRule::WEEKLY,
|
|
new \DateTimeImmutable('2026-09-18'),
|
|
new \DateTimeImmutable('2026-10-02'),
|
|
TimeRange::fromMinutes(540, 780),
|
|
);
|
|
|
|
$this->audit->seriesEnded($series, 42, new \DateTimeImmutable('2026-09-25'), 2);
|
|
|
|
$record = $this->record();
|
|
|
|
self::assertSame(42, $record->context['series_id']);
|
|
self::assertSame('2026-09-25', $record->context['ended_from']);
|
|
self::assertSame(2, $record->context['bookings_removed']);
|
|
}
|
|
|
|
public function testRetiringAResourceIsRecordedAsTheTransitionItIs(): void
|
|
{
|
|
$resource = $this->resource();
|
|
$resource->setGroup(new ResourceGroup(ResourceType::DESK, 'Room 1'));
|
|
$resource->setActive(false);
|
|
|
|
$this->audit->resourceUpdated($resource, true);
|
|
|
|
$record = $this->record();
|
|
|
|
self::assertSame('Resource updated.', $record->message);
|
|
self::assertTrue($record->context['was_active']);
|
|
self::assertFalse($record->context['active']);
|
|
self::assertSame('Room 1', $record->context['group']);
|
|
}
|
|
|
|
public function testADeletedResourceIsRecordedWithHowManyBookingsWentWithIt(): void
|
|
{
|
|
$this->audit->resourceDeleted(7, 'D1', ResourceType::DESK, 3);
|
|
|
|
$record = $this->record();
|
|
|
|
self::assertSame('Resource deleted.', $record->message);
|
|
self::assertSame(7, $record->context['resource_id']);
|
|
self::assertSame(3, $record->context['bookings_deleted']);
|
|
}
|
|
|
|
public function testASignInAndASignOutAreBothRecordedAgainstTheAccount(): void
|
|
{
|
|
$this->audit->signedIn($this->user());
|
|
$this->audit->signedOut($this->user());
|
|
|
|
$records = $this->handler->getRecords();
|
|
|
|
self::assertSame('User signed in.', $records[0]->message);
|
|
self::assertSame('[email protected]', $records[0]->context['account']);
|
|
self::assertSame('User signed out.', $records[1]->message);
|
|
self::assertSame('[email protected]', $records[1]->context['account']);
|
|
}
|
|
|
|
/**
|
|
* The one thing the trail must never carry. It is asserted rather than left to review,
|
|
* because a helpful "log what changed" added later would leak every door code in the
|
|
* building into a file that outlives the codes themselves.
|
|
*/
|
|
public function testAnAccessCodeIsRecordedWithoutItsValue(): void
|
|
{
|
|
$code = new AccessCode('Aufzug', '4711', new \DateTimeImmutable('2026-10-01'));
|
|
|
|
$this->audit->accessCodeCreated($code);
|
|
$this->audit->accessCodeUpdated($code, true, CodeVisibility::ADMIN);
|
|
$this->audit->accessCodeDeleted(7, 'Aufzug', CodeVisibility::EVERYONE);
|
|
|
|
foreach ($this->handler->getRecords() as $record) {
|
|
self::assertStringNotContainsString('4711', json_encode($record->context, \JSON_THROW_ON_ERROR));
|
|
}
|
|
|
|
$records = $this->handler->getRecords();
|
|
self::assertSame('Aufzug', $records[0]->context['access_code']);
|
|
self::assertSame('2026-10-01', $records[0]->context['valid_from']);
|
|
self::assertSame('everyone', $records[0]->context['visibility']);
|
|
self::assertTrue($records[1]->context['code_changed']);
|
|
self::assertSame('admin', $records[1]->context['previous_visibility']);
|
|
self::assertSame(7, $records[2]->context['access_code_id']);
|
|
}
|
|
|
|
private function record(): LogRecord
|
|
{
|
|
return $this->handler->getRecords()[0];
|
|
}
|
|
|
|
private function booking(): Booking
|
|
{
|
|
return new Booking(
|
|
$this->user(),
|
|
$this->resource(),
|
|
new \DateTimeImmutable('2026-09-18'),
|
|
TimeRange::fromMinutes(540, 780),
|
|
);
|
|
}
|
|
|
|
private function user(): User
|
|
{
|
|
return new User('[email protected]', 'Holder');
|
|
}
|
|
|
|
private function resource(): Resource
|
|
{
|
|
return new Resource(ResourceType::DESK, 'D1');
|
|
}
|
|
}
|