diff --git a/css/app.scss b/css/app.scss index 2c2347c2e..9ddcc5215 100644 --- a/css/app.scss +++ b/css/app.scss @@ -1863,3 +1863,23 @@ body { background-color: #ed9e2e; } } + +.audit-log-table { + table-layout: fixed; + + .audit-log-datetime { + width: 200px; + + @media (max-width: 767px) { + width: 130px; + } + } + + .audit-log-type { + width: 180px; + + @media (max-width: 767px) { + width: 120px; + } + } +} diff --git a/src/Audit/Display/AbstractAuditLogDisplay.php b/src/Audit/Display/AbstractAuditLogDisplay.php new file mode 100644 index 000000000..1a5165867 --- /dev/null +++ b/src/Audit/Display/AbstractAuditLogDisplay.php @@ -0,0 +1,42 @@ + + * Nils Adermann + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Audit\Display; + +use App\Audit\AuditRecordType; + +abstract readonly class AbstractAuditLogDisplay implements AuditLogDisplayInterface +{ + public function __construct( + public \DateTimeImmutable $datetime, + public ActorDisplay $actor, + ) { + } + + abstract public function getType(): AuditRecordType; + + public function getDateTime(): \DateTimeImmutable + { + return $this->datetime; + } + + public function getActor(): ActorDisplay + { + return $this->actor; + } + + public function getTypeTranslationKey(): string + { + return 'audit_log.type.' . $this->getType()->value; + } + +} diff --git a/src/Audit/Display/ActorDisplay.php b/src/Audit/Display/ActorDisplay.php new file mode 100644 index 000000000..137ba271d --- /dev/null +++ b/src/Audit/Display/ActorDisplay.php @@ -0,0 +1,22 @@ + + * Nils Adermann + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Audit\Display; + +readonly class ActorDisplay +{ + public function __construct( + public ?int $id, + public string $username, + ) { + } +} diff --git a/src/Audit/Display/AuditLogDisplayFactory.php b/src/Audit/Display/AuditLogDisplayFactory.php new file mode 100644 index 000000000..6b5adc905 --- /dev/null +++ b/src/Audit/Display/AuditLogDisplayFactory.php @@ -0,0 +1,91 @@ + + * Nils Adermann + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Audit\Display; + +use App\Audit\AuditRecordType; +use App\Entity\AuditRecord; + +class AuditLogDisplayFactory +{ + /** + * @param iterable $auditRecords + * @return array + */ + public function build(iterable $auditRecords): array + { + $displays = []; + foreach ($auditRecords as $record) { + $displays[] = $this->buildSingle($record); + } + + return $displays; + } + + public function buildSingle(AuditRecord $record): AuditLogDisplayInterface + { + return match ($record->type) { + AuditRecordType::PackageCreated => new PackageCreatedDisplay( + $record->datetime, + $record->attributes['name'], + $record->attributes['repository'], + $this->buildActor($record->attributes['actor']), + ), + AuditRecordType::PackageDeleted => new PackageDeletedDisplay( + $record->datetime, + $record->attributes['name'], + $record->attributes['repository'], + $this->buildActor($record->attributes['actor']), + ), + AuditRecordType::CanonicalUrlChanged => new CanonicalUrlChangedDisplay( + $record->datetime, + $record->attributes['name'], + $record->attributes['repository_from'], + $record->attributes['repository_to'], + $this->buildActor($record->attributes['actor']), + ), + AuditRecordType::VersionDeleted => new VersionDeletedDisplay( + $record->datetime, + $record->attributes['name'], + $record->attributes['version'], + $this->buildActor($record->attributes['actor']), + ), + AuditRecordType::VersionReferenceChanged => new VersionReferenceChangedDisplay( + $record->datetime, + $record->attributes['name'], + $record->attributes['version'], + $record->attributes['source_from'] ?? null, + $record->attributes['source_to'] ?? null, + $record->attributes['dist_from'] ?? null, + $record->attributes['dist_to'] ?? null, + $this->buildActor($record->attributes['actor'] ?? null), + ), + default => throw new \LogicException(sprintf('Unsupported audit record type: %s', $record->type->value)), + }; + } + + /** + * @param array{id: int, username: string}|string|null $actor + */ + private function buildActor(array|string|null $actor): ActorDisplay + { + if ($actor === null) { + return new ActorDisplay(null, 'unknown'); + } + + if (is_string($actor)) { + return new ActorDisplay(null, $actor); + } + + return new ActorDisplay($actor['id'], $actor['username']); + } +} diff --git a/src/Audit/Display/AuditLogDisplayInterface.php b/src/Audit/Display/AuditLogDisplayInterface.php new file mode 100644 index 000000000..dc9f6f113 --- /dev/null +++ b/src/Audit/Display/AuditLogDisplayInterface.php @@ -0,0 +1,28 @@ + + * Nils Adermann + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Audit\Display; + +use App\Audit\AuditRecordType; + +interface AuditLogDisplayInterface +{ + public function getType(): AuditRecordType; + + public function getDateTime(): \DateTimeImmutable; + + public function getActor(): ActorDisplay; + + public function getTypeTranslationKey(): string; + + public function getTemplateName(): string; +} diff --git a/src/Audit/Display/CanonicalUrlChangedDisplay.php b/src/Audit/Display/CanonicalUrlChangedDisplay.php new file mode 100644 index 000000000..862437ab4 --- /dev/null +++ b/src/Audit/Display/CanonicalUrlChangedDisplay.php @@ -0,0 +1,38 @@ + + * Nils Adermann + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Audit\Display; + +use App\Audit\AuditRecordType; + +readonly class CanonicalUrlChangedDisplay extends AbstractAuditLogDisplay +{ + public function __construct( + \DateTimeImmutable $datetime, + public string $packageName, + public string $repositoryFrom, + public string $repositoryTo, + ActorDisplay $actor, + ) { + parent::__construct($datetime, $actor); + } + + public function getType(): AuditRecordType + { + return AuditRecordType::CanonicalUrlChanged; + } + + public function getTemplateName(): string + { + return 'audit_log/display/canonical_url_changed.html.twig'; + } +} diff --git a/src/Audit/Display/PackageCreatedDisplay.php b/src/Audit/Display/PackageCreatedDisplay.php new file mode 100644 index 000000000..2297a194f --- /dev/null +++ b/src/Audit/Display/PackageCreatedDisplay.php @@ -0,0 +1,37 @@ + + * Nils Adermann + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Audit\Display; + +use App\Audit\AuditRecordType; + +readonly class PackageCreatedDisplay extends AbstractAuditLogDisplay +{ + public function __construct( + \DateTimeImmutable $datetime, + public string $packageName, + public string $repository, + ActorDisplay $actor, + ) { + parent::__construct($datetime, $actor); + } + + public function getType(): AuditRecordType + { + return AuditRecordType::PackageCreated; + } + + public function getTemplateName(): string + { + return 'audit_log/display/package_created.html.twig'; + } +} \ No newline at end of file diff --git a/src/Audit/Display/PackageDeletedDisplay.php b/src/Audit/Display/PackageDeletedDisplay.php new file mode 100644 index 000000000..8bf026e10 --- /dev/null +++ b/src/Audit/Display/PackageDeletedDisplay.php @@ -0,0 +1,37 @@ + + * Nils Adermann + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Audit\Display; + +use App\Audit\AuditRecordType; + +readonly class PackageDeletedDisplay extends AbstractAuditLogDisplay +{ + public function __construct( + \DateTimeImmutable $datetime, + public string $packageName, + public string $repository, + ActorDisplay $actor, + ) { + parent::__construct($datetime, $actor); + } + + public function getType(): AuditRecordType + { + return AuditRecordType::PackageDeleted; + } + + public function getTemplateName(): string + { + return 'audit_log/display/package_deleted.html.twig'; + } +} diff --git a/src/Audit/Display/VersionDeletedDisplay.php b/src/Audit/Display/VersionDeletedDisplay.php new file mode 100644 index 000000000..ef470abde --- /dev/null +++ b/src/Audit/Display/VersionDeletedDisplay.php @@ -0,0 +1,37 @@ + + * Nils Adermann + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Audit\Display; + +use App\Audit\AuditRecordType; + +readonly class VersionDeletedDisplay extends AbstractAuditLogDisplay +{ + public function __construct( + \DateTimeImmutable $datetime, + public string $packageName, + public string $version, + ActorDisplay $actor, + ) { + parent::__construct($datetime, $actor); + } + + public function getType(): AuditRecordType + { + return AuditRecordType::VersionDeleted; + } + + public function getTemplateName(): string + { + return 'audit_log/display/version_deleted.html.twig'; + } +} diff --git a/src/Audit/Display/VersionReferenceChangedDisplay.php b/src/Audit/Display/VersionReferenceChangedDisplay.php new file mode 100644 index 000000000..0a625de28 --- /dev/null +++ b/src/Audit/Display/VersionReferenceChangedDisplay.php @@ -0,0 +1,41 @@ + + * Nils Adermann + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Audit\Display; + +use App\Audit\AuditRecordType; + +readonly class VersionReferenceChangedDisplay extends AbstractAuditLogDisplay +{ + public function __construct( + \DateTimeImmutable $datetime, + public string $packageName, + public string $version, + public ?string $sourceFrom, + public ?string $sourceTo, + public ?string $distFrom, + public ?string $distTo, + ActorDisplay $actor, + ) { + parent::__construct($datetime, $actor); + } + + public function getType(): AuditRecordType + { + return AuditRecordType::VersionReferenceChanged; + } + + public function getTemplateName(): string + { + return 'audit_log/display/version_reference_changed.html.twig'; + } +} diff --git a/src/Controller/AuditLogController.php b/src/Controller/AuditLogController.php index fefb7290e..0e58d9a74 100644 --- a/src/Controller/AuditLogController.php +++ b/src/Controller/AuditLogController.php @@ -13,6 +13,7 @@ namespace App\Controller; use App\Audit\AuditRecordType; +use App\Audit\Display\AuditLogDisplayFactory; use App\Entity\AuditRecordRepository; use App\QueryFilter\AuditLog\AuditRecordTypeFilter; use App\QueryFilter\QueryFilterInterface; @@ -27,7 +28,7 @@ class AuditLogController extends Controller { #[IsGranted('ROLE_USER')] #[Route(path: '/audit-log', name: 'view_audit_logs')] - public function viewAuditLogs(Request $request, AuditRecordRepository $auditRecordRepository): Response + public function viewAuditLogs(Request $request, AuditRecordRepository $auditRecordRepository, AuditLogDisplayFactory $displayFactory): Response { /** @var QueryFilterInterface[] $filters */ $filters = [ @@ -52,7 +53,8 @@ public function viewAuditLogs(Request $request, AuditRecordRepository $auditReco } return $this->render('audit_log/view_audit_logs.html.twig', [ - 'auditLogs' => $auditLogs, + 'auditLogDisplays' => $displayFactory->build($auditLogs), + 'auditLogPaginator' => $auditLogs, 'allTypes' => AuditRecordType::cases(), 'selectedFilters' => $selectedFilters, ]); diff --git a/templates/audit_log/display/canonical_url_changed.html.twig b/templates/audit_log/display/canonical_url_changed.html.twig new file mode 100644 index 000000000..8e565037b --- /dev/null +++ b/templates/audit_log/display/canonical_url_changed.html.twig @@ -0,0 +1,4 @@ +{{ display.packageName }}
+From: {{ display.repositoryFrom }}
+To: {{ display.repositoryTo }}
+Changed by: {{ display.actor.username }} diff --git a/templates/audit_log/display/package_created.html.twig b/templates/audit_log/display/package_created.html.twig new file mode 100644 index 000000000..06105c145 --- /dev/null +++ b/templates/audit_log/display/package_created.html.twig @@ -0,0 +1,3 @@ +{{ display.packageName }}
+Repository: {{ display.repository }}
+Created by: {{ display.actor.username }} diff --git a/templates/audit_log/display/package_deleted.html.twig b/templates/audit_log/display/package_deleted.html.twig new file mode 100644 index 000000000..a0df77979 --- /dev/null +++ b/templates/audit_log/display/package_deleted.html.twig @@ -0,0 +1,3 @@ +{{ display.packageName }}
+Repository: {{ display.repository }}
+Deleted by: {{ display.actor.username }} diff --git a/templates/audit_log/display/version_deleted.html.twig b/templates/audit_log/display/version_deleted.html.twig new file mode 100644 index 000000000..86182b850 --- /dev/null +++ b/templates/audit_log/display/version_deleted.html.twig @@ -0,0 +1,2 @@ +{{ display.packageName }} {{ display.version }}
+Deleted by: {{ display.actor.username }} diff --git a/templates/audit_log/display/version_reference_changed.html.twig b/templates/audit_log/display/version_reference_changed.html.twig new file mode 100644 index 000000000..1dad07f61 --- /dev/null +++ b/templates/audit_log/display/version_reference_changed.html.twig @@ -0,0 +1,8 @@ +{{ display.packageName }} {{ display.version }}
+{% if display.sourceFrom or display.sourceTo %} + Source: {{ display.sourceFrom ?? 'none' }} → {{ display.sourceTo ?? 'none' }}
+{% endif %} +{% if display.distFrom or display.distTo %} + Dist: {{ display.distFrom ?? 'none' }} → {{ display.distTo ?? 'none' }}
+{% endif %} +Changed by: {{ display.actor.username }} diff --git a/templates/audit_log/view_audit_logs.html.twig b/templates/audit_log/view_audit_logs.html.twig index 27b5b5ff2..1f2807567 100644 --- a/templates/audit_log/view_audit_logs.html.twig +++ b/templates/audit_log/view_audit_logs.html.twig @@ -28,28 +28,30 @@ - {% if auditLogs|length %} - + {% if auditLogDisplays|length %} +
- - - - - + + + + + - {% for log in auditLogs %} + {% for display in auditLogDisplays %} - - - + + + {% endfor %}
Date & TimeTypePackage
Date & TimeTypeDetails
{{ log.datetime|date('Y-m-d H:i:s') }} UTC{{ ('audit_log.type.'~log.type.value)|trans }}{{ log.attributes['name'] ?? '-' }}{{ display.dateTime|date('Y-m-d H:i:s') }} UTC{{ display.typeTranslationKey|trans }} + {% include display.templateName with {display} only %} +
- {% if auditLogs.haveToPaginate() %} - {{ pagerfanta(auditLogs, 'twitter_bootstrap', {'proximity': 2}) }} + {% if auditLogPaginator.haveToPaginate() %} + {{ pagerfanta(auditLogPaginator, 'twitter_bootstrap', {'proximity': 2}) }} {% endif %} {% else %}
diff --git a/tests/Audit/Display/AuditLogDisplayFactoryTest.php b/tests/Audit/Display/AuditLogDisplayFactoryTest.php new file mode 100644 index 000000000..3f9fe8254 --- /dev/null +++ b/tests/Audit/Display/AuditLogDisplayFactoryTest.php @@ -0,0 +1,326 @@ + + * Nils Adermann + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Tests\Audit\Display; + +use App\Audit\AuditRecordType; +use App\Audit\Display\ActorDisplay; +use App\Audit\Display\AuditLogDisplayFactory; +use App\Audit\Display\CanonicalUrlChangedDisplay; +use App\Audit\Display\PackageCreatedDisplay; +use App\Audit\Display\PackageDeletedDisplay; +use App\Audit\Display\VersionDeletedDisplay; +use App\Audit\Display\VersionReferenceChangedDisplay; +use App\Entity\AuditRecord; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +class AuditLogDisplayFactoryTest extends TestCase +{ + private AuditLogDisplayFactory $factory; + + protected function setUp(): void + { + $this->factory = new AuditLogDisplayFactory(); + } + + public function testBuildPackageCreatedWithUserActor(): void + { + $auditRecord = $this->createAuditRecord( + AuditRecordType::PackageCreated, + [ + 'name' => 'vendor/package', + 'repository' => 'https://github.com/vendor/package', + 'actor' => ['id' => 123, 'username' => 'testuser'], + ] + ); + + $display = $this->factory->buildSingle($auditRecord); + + self::assertInstanceOf(PackageCreatedDisplay::class, $display); + self::assertSame('vendor/package', $display->packageName); + self::assertSame('https://github.com/vendor/package', $display->repository); + self::assertSame(123, $display->actor->id); + self::assertSame('testuser', $display->actor->username); + self::assertSame(AuditRecordType::PackageCreated, $display->getType()); + self::assertSame('audit_log/display/package_created.html.twig', $display->getTemplateName()); + self::assertSame('audit_log.type.package_created', $display->getTypeTranslationKey()); + } + + public function testBuildPackageCreatedWithSystemActor(): void + { + $auditRecord = $this->createAuditRecord( + AuditRecordType::PackageCreated, + [ + 'name' => 'vendor/package', + 'repository' => 'https://github.com/vendor/package', + 'actor' => 'automation', + ] + ); + + $display = $this->factory->buildSingle($auditRecord); + + self::assertInstanceOf(PackageCreatedDisplay::class, $display); + self::assertNull($display->actor->id); + self::assertSame('automation', $display->actor->username); + } + + public function testBuildPackageDeleted(): void + { + $auditRecord = $this->createAuditRecord( + AuditRecordType::PackageDeleted, + [ + 'name' => 'vendor/package', + 'repository' => 'https://github.com/vendor/package', + 'actor' => ['id' => 456, 'username' => 'admin'], + ] + ); + + $display = $this->factory->buildSingle($auditRecord); + + self::assertInstanceOf(PackageDeletedDisplay::class, $display); + self::assertSame('vendor/package', $display->packageName); + self::assertSame('https://github.com/vendor/package', $display->repository); + self::assertSame(456, $display->actor->id); + self::assertSame('admin', $display->actor->username); + self::assertSame(AuditRecordType::PackageDeleted, $display->getType()); + self::assertSame('audit_log/display/package_deleted.html.twig', $display->getTemplateName()); + } + + public function testBuildCanonicalUrlChanged(): void + { + $auditRecord = $this->createAuditRecord( + AuditRecordType::CanonicalUrlChanged, + [ + 'name' => 'vendor/package', + 'repository_from' => 'https://github.com/vendor/old-package', + 'repository_to' => 'https://github.com/vendor/new-package', + 'actor' => ['id' => 789, 'username' => 'maintainer'], + ] + ); + + $display = $this->factory->buildSingle($auditRecord); + + self::assertInstanceOf(CanonicalUrlChangedDisplay::class, $display); + self::assertSame('vendor/package', $display->packageName); + self::assertSame('https://github.com/vendor/old-package', $display->repositoryFrom); + self::assertSame('https://github.com/vendor/new-package', $display->repositoryTo); + self::assertSame(789, $display->actor->id); + self::assertSame('maintainer', $display->actor->username); + self::assertSame(AuditRecordType::CanonicalUrlChanged, $display->getType()); + self::assertSame('audit_log/display/canonical_url_changed.html.twig', $display->getTemplateName()); + } + + public function testBuildVersionDeleted(): void + { + $auditRecord = $this->createAuditRecord( + AuditRecordType::VersionDeleted, + [ + 'name' => 'vendor/package', + 'version' => '1.0.0', + 'actor' => ['id' => 111, 'username' => 'moderator'], + ] + ); + + $display = $this->factory->buildSingle($auditRecord); + + self::assertInstanceOf(VersionDeletedDisplay::class, $display); + self::assertSame('vendor/package', $display->packageName); + self::assertSame('1.0.0', $display->version); + self::assertSame(111, $display->actor->id); + self::assertSame('moderator', $display->actor->username); + self::assertSame(AuditRecordType::VersionDeleted, $display->getType()); + self::assertSame('audit_log/display/version_deleted.html.twig', $display->getTemplateName()); + } + + public function testBuildVersionReferenceChangedWithSourceOnly(): void + { + $auditRecord = $this->createAuditRecord( + AuditRecordType::VersionReferenceChanged, + [ + 'name' => 'vendor/package', + 'version' => '2.0.0', + 'source_from' => 'abc123', + 'source_to' => 'def456', + 'actor' => ['id' => 222, 'username' => 'releaser'], + ] + ); + + $display = $this->factory->buildSingle($auditRecord); + + self::assertInstanceOf(VersionReferenceChangedDisplay::class, $display); + self::assertSame('vendor/package', $display->packageName); + self::assertSame('2.0.0', $display->version); + self::assertSame('abc123', $display->sourceFrom); + self::assertSame('def456', $display->sourceTo); + self::assertNull($display->distFrom); + self::assertNull($display->distTo); + self::assertSame(222, $display->getActor()->id); + self::assertSame('releaser', $display->getActor()->username); + self::assertSame(AuditRecordType::VersionReferenceChanged, $display->getType()); + self::assertSame('audit_log/display/version_reference_changed.html.twig', $display->getTemplateName()); + } + + public function testBuildVersionReferenceChangedWithDistOnly(): void + { + $auditRecord = $this->createAuditRecord( + AuditRecordType::VersionReferenceChanged, + [ + 'name' => 'vendor/package', + 'version' => '2.0.0', + 'dist_from' => 'xyz789', + 'dist_to' => 'uvw012', + 'actor' => ['id' => 333, 'username' => 'updater'], + ] + ); + + $display = $this->factory->buildSingle($auditRecord); + + self::assertInstanceOf(VersionReferenceChangedDisplay::class, $display); + self::assertNull($display->sourceFrom); + self::assertNull($display->sourceTo); + self::assertSame('xyz789', $display->distFrom); + self::assertSame('uvw012', $display->distTo); + self::assertSame(333, $display->getActor()->id); + self::assertSame('updater', $display->getActor()->username); + } + + public function testBuildVersionReferenceChangedWithBothSourceAndDist(): void + { + $auditRecord = $this->createAuditRecord( + AuditRecordType::VersionReferenceChanged, + [ + 'name' => 'vendor/package', + 'version' => '3.0.0', + 'source_from' => 'abc123', + 'source_to' => 'def456', + 'dist_from' => 'xyz789', + 'dist_to' => 'uvw012', + 'actor' => ['id' => 444, 'username' => 'publisher'], + ] + ); + + $display = $this->factory->buildSingle($auditRecord); + + self::assertInstanceOf(VersionReferenceChangedDisplay::class, $display); + self::assertSame('abc123', $display->sourceFrom); + self::assertSame('def456', $display->sourceTo); + self::assertSame('xyz789', $display->distFrom); + self::assertSame('uvw012', $display->distTo); + self::assertSame(444, $display->getActor()->id); + self::assertSame('publisher', $display->getActor()->username); + } + + public function testBuildVersionReferenceChangedWithoutActor(): void + { + $auditRecord = $this->createAuditRecord( + AuditRecordType::VersionReferenceChanged, + [ + 'name' => 'vendor/package', + 'version' => '4.0.0', + 'source_from' => 'abc123', + 'source_to' => 'def456', + ] + ); + + $display = $this->factory->buildSingle($auditRecord); + + self::assertInstanceOf(VersionReferenceChangedDisplay::class, $display); + self::assertSame('vendor/package', $display->packageName); + self::assertSame('4.0.0', $display->version); + self::assertNull($display->getActor()->id); + self::assertSame('unknown', $display->getActor()->username); + } + + public function testBuildMultipleRecords(): void + { + $records = [ + $this->createAuditRecord( + AuditRecordType::PackageCreated, + [ + 'name' => 'vendor/package1', + 'repository' => 'https://github.com/vendor/package1', + 'actor' => ['id' => 1, 'username' => 'user1'], + ] + ), + $this->createAuditRecord( + AuditRecordType::PackageDeleted, + [ + 'name' => 'vendor/package2', + 'repository' => 'https://github.com/vendor/package2', + 'actor' => ['id' => 2, 'username' => 'user2'], + ] + ), + $this->createAuditRecord( + AuditRecordType::VersionDeleted, + [ + 'name' => 'vendor/package3', + 'version' => '1.0.0', + 'actor' => ['id' => 3, 'username' => 'user3'], + ] + ), + ]; + + $displays = $this->factory->build($records); + + self::assertCount(3, $displays); + self::assertInstanceOf(PackageCreatedDisplay::class, $displays[0]); + self::assertInstanceOf(PackageDeletedDisplay::class, $displays[1]); + self::assertInstanceOf(VersionDeletedDisplay::class, $displays[2]); + self::assertSame('vendor/package1', $displays[0]->packageName); + self::assertSame('vendor/package2', $displays[1]->packageName); + self::assertSame('vendor/package3', $displays[2]->packageName); + } + + public function testDateTimeIsPreserved(): void + { + $datetime = new \DateTimeImmutable('2024-01-15 10:30:00'); + $auditRecord = $this->createAuditRecord( + AuditRecordType::PackageCreated, + [ + 'name' => 'vendor/package', + 'repository' => 'https://github.com/vendor/package', + 'actor' => ['id' => 1, 'username' => 'user'], + ], + $datetime + ); + + $display = $this->factory->buildSingle($auditRecord); + + self::assertSame($datetime, $display->getDateTime()); + } + + /** + * @param array $attributes + */ + private function createAuditRecord( + AuditRecordType $type, + array $attributes, + ?\DateTimeImmutable $datetime = null + ): AuditRecord { + $datetime = $datetime ?? new \DateTimeImmutable(); + + $reflection = new \ReflectionClass(AuditRecord::class); + $instance = $reflection->newInstanceWithoutConstructor(); + + $datetimeProperty = $reflection->getProperty('datetime'); + $datetimeProperty->setValue($instance, $datetime); + + $typeProperty = $reflection->getProperty('type'); + $typeProperty->setValue($instance, $type); + + $attributesProperty = $reflection->getProperty('attributes'); + $attributesProperty->setValue($instance, $attributes); + + return $instance; + } +}