diff --git a/app/Audit/AbstractAuditLogFormatter.php b/app/Audit/AbstractAuditLogFormatter.php index 94f614e84..d5f088342 100644 --- a/app/Audit/AbstractAuditLogFormatter.php +++ b/app/Audit/AbstractAuditLogFormatter.php @@ -3,6 +3,8 @@ namespace App\Audit; use App\Audit\Utils\DateFormatter; +use Doctrine\ORM\PersistentCollection; +use Illuminate\Support\Facades\Log; /** * Copyright 2025 OpenStack Foundation @@ -32,10 +34,73 @@ final public function setContext(AuditContext $ctx): void $this->ctx = $ctx; } + protected function handleManyToManyCollection(array $change_set): ?PersistentCollectionMetadata + { + if (!isset($change_set['collection'])) { + return null; + } + + $collection = $change_set['collection']; + if (!($collection instanceof PersistentCollection)) { + return null; + } + + $preloadedDeletedIds = $change_set['deleted_ids'] ?? []; + if (!is_array($preloadedDeletedIds)) { + $preloadedDeletedIds = []; + } + + return PersistentCollectionMetadata::fromCollection($collection, $preloadedDeletedIds); + } + + protected function processCollection(PersistentCollectionMetadata $metadata): ?array + { + $addedIds = []; + $removedIds = []; + + if (!empty($metadata->preloadedDeletedIds)) { + $removedIds = array_values(array_unique(array_map('intval', $metadata->preloadedDeletedIds))); + sort($removedIds); + } else { + $addedIds = $this->extractCollectionEntityIds($metadata->collection->getInsertDiff()); + $removedIds = $this->extractCollectionEntityIds($metadata->collection->getDeleteDiff()); + } + + return [ + 'field' => $metadata->fieldName, + 'target_entity' => class_basename($metadata->targetEntity), + 'is_deletion' => $this->event_type === Interfaces\IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE, + 'added_ids' => $addedIds, + 'removed_ids' => $removedIds, + ]; + } + + + /** + * Extract IDs from entity objects in collection + */ + protected function extractCollectionEntityIds(array $entities): array + { + $ids = []; + foreach ($entities as $entity) { + if (method_exists($entity, 'getId')) { + $id = $entity->getId(); + if ($id !== null) { + $ids[] = $id; + } + } + } + + $uniqueIds = array_unique($ids); + sort($uniqueIds); + + return array_values($uniqueIds); + } + protected function getUserInfo(): string { if (app()->runningInConsole()) { - return 'Worker Job'; + return 'Worker Job'; } if (!$this->ctx) { return 'Unknown (unknown)'; @@ -129,5 +194,57 @@ protected function formatFieldChange(string $prop_name, $old_value, $new_value): return sprintf("Property \"%s\" has changed from \"%s\" to \"%s\"", $prop_name, $old_display, $new_display); } + /** + * Build detailed message for many-to-many collection changes + */ + protected function buildManyToManyDetailedMessage(PersistentCollection $collection, array $insertDiff, array $deleteDiff): array + { + $fieldName = 'unknown'; + $targetEntity = 'unknown'; + + try { + $mapping = $collection->getMapping(); + $fieldName = $mapping->fieldName ?? 'unknown'; + $targetEntity = $mapping->targetEntity ?? 'unknown'; + if ($targetEntity) { + $targetEntity = class_basename($targetEntity); + } + } catch (\Exception $e) { + Log::debug("AbstractAuditLogFormatter::Could not extract collection metadata: " . $e->getMessage()); + } + + $addedIds = $this->extractCollectionEntityIds($insertDiff); + $removedIds = $this->extractCollectionEntityIds($deleteDiff); + + return [ + 'field' => $fieldName, + 'target_entity' => $targetEntity, + 'added_ids' => $addedIds, + 'removed_ids' => $removedIds, + ]; + } + + /** + * Format detailed message for many-to-many collection changes + */ + protected static function formatManyToManyDetailedMessage(array $details, int $addCount, int $removeCount, string $action): string + { + $field = $details['field'] ?? 'unknown'; + $target = $details['target_entity'] ?? 'unknown'; + $addedIds = $details['added_ids'] ?? []; + $removedIds = $details['removed_ids'] ?? []; + + $parts = []; + if (!empty($addedIds)) { + $parts[] = sprintf("Added %d %s(s): %s", $addCount, $target, implode(', ', $addedIds)); + } + if (!empty($removedIds)) { + $parts[] = sprintf("Removed %d %s(s): %s", $removeCount, $target, implode(', ', $removedIds)); + } + + $detailStr = implode(' | ', $parts); + return sprintf("Many-to-Many collection '%s' %s: %s", $field, $action, $detailStr); + } + abstract public function format(mixed $subject, array $change_set): ?string; } diff --git a/app/Audit/AuditEventListener.php b/app/Audit/AuditEventListener.php index b6cf8baf2..35c6ff244 100644 --- a/app/Audit/AuditEventListener.php +++ b/app/Audit/AuditEventListener.php @@ -14,6 +14,8 @@ use App\Audit\Interfaces\IAuditStrategy; use Doctrine\ORM\Event\OnFlushEventArgs; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\PersistentCollection; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Route; @@ -25,16 +27,16 @@ class AuditEventListener { private const ROUTE_METHOD_SEPARATOR = '|'; - + private $em; public function onFlush(OnFlushEventArgs $eventArgs): void { if (app()->environment('testing')) { return; } - $em = $eventArgs->getObjectManager(); - $uow = $em->getUnitOfWork(); + $this->em = $eventArgs->getObjectManager(); + $uow = $this->em->getUnitOfWork(); // Strategy selection based on environment configuration - $strategy = $this->getAuditStrategy($em); + $strategy = $this->getAuditStrategy($this->em); if (!$strategy) { return; // No audit strategy enabled } @@ -52,11 +54,23 @@ public function onFlush(OnFlushEventArgs $eventArgs): void foreach ($uow->getScheduledEntityDeletions() as $entity) { $strategy->audit($entity, [], IAuditStrategy::EVENT_ENTITY_DELETION, $ctx); + } + foreach ($uow->getScheduledCollectionDeletions() as $col) { + [$subject, $payload, $eventType] = $this->auditCollection($col, $uow, IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE); + + if (!is_null($subject)) { + $strategy->audit($subject, $payload, $eventType, $ctx); + } } foreach ($uow->getScheduledCollectionUpdates() as $col) { - $strategy->audit($col, [], IAuditStrategy::EVENT_COLLECTION_UPDATE, $ctx); + [$subject, $payload, $eventType] = $this->auditCollection($col, $uow, IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + + if (!is_null($subject)) { + $strategy->audit($subject, $payload, $eventType, $ctx); + } } + } catch (\Exception $e) { Log::error('Audit event listener failed', [ 'error' => $e->getMessage(), @@ -98,7 +112,7 @@ private function buildAuditContext(): AuditContext $member = $memberRepo->findOneBy(["user_external_id" => $userExternalId]); } - //$ui = app()->bound('ui.context') ? app('ui.context') : []; + $ui = []; $req = request(); $rawRoute = null; @@ -127,4 +141,102 @@ private function buildAuditContext(): AuditContext rawRoute: $rawRoute ); } + + /** + * Audit collection changes + * Returns triple: [$subject, $payload, $eventType] + * Subject will be null if collection should not be audited + * + * @param object $subject The collection + * @param mixed $uow The UnitOfWork + * @param string $eventType The event type constant (EVENT_COLLECTION_MANYTOMANY_DELETE or EVENT_COLLECTION_MANYTOMANY_UPDATE) + * @return array [$subject, $payload, $eventType] + */ + private function auditCollection($subject, $uow, string $eventType): array + { + if (!$subject instanceof PersistentCollection) { + return [null, null, null]; + } + + $mapping = $subject->getMapping(); + + if (!$mapping->isManyToMany()) { + return [$subject, [], IAuditStrategy::EVENT_COLLECTION_UPDATE]; + } + + if (!$mapping->isOwningSide()) { + Log::debug("AuditEventListener::Skipping audit for non-owning side of many-to-many collection"); + return [null, null, null]; + } + + $owner = $subject->getOwner(); + if ($owner === null) { + return [null, null, null]; + } + + $payload = ['collection' => $subject]; + + if ($eventType === IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE + && ( + !$subject->isInitialized() + || ($subject->isInitialized() && count($subject->getDeleteDiff()) === 0) + )) { + if ($this->em instanceof EntityManagerInterface) { + $payload['deleted_ids'] = $this->fetchManyToManyIds($subject, $this->em); + } + } + + return [$owner, $payload, $eventType]; + } + + + private function fetchManyToManyIds(PersistentCollection $collection, EntityManagerInterface $em): array + { + try { + $mapping = $collection->getMapping(); + $joinTable = $mapping->joinTable; + $tableName = is_array($joinTable) ? ($joinTable['name'] ?? null) : ($joinTable->name ?? null); + $joinColumns = is_array($joinTable) ? ($joinTable['joinColumns'] ?? []) : ($joinTable->joinColumns ?? []); + $inverseJoinColumns = is_array($joinTable) ? ($joinTable['inverseJoinColumns'] ?? []) : ($joinTable->inverseJoinColumns ?? []); + + $joinColumn = $joinColumns[0] ?? null; + $inverseJoinColumn = $inverseJoinColumns[0] ?? null; + $sourceColumn = is_array($joinColumn) ? ($joinColumn['name'] ?? null) : ($joinColumn->name ?? null); + $targetColumn = is_array($inverseJoinColumn) ? ($inverseJoinColumn['name'] ?? null) : ($inverseJoinColumn->name ?? null); + + if (!$sourceColumn || !$targetColumn || !$tableName) { + return []; + } + + $owner = $collection->getOwner(); + if ($owner === null) { + return []; + } + + $ownerId = method_exists($owner, 'getId') ? $owner->getId() : null; + if ($ownerId === null) { + $ownerMeta = $em->getClassMetadata(get_class($owner)); + $ownerIds = $ownerMeta->getIdentifierValues($owner); + $ownerId = empty($ownerIds) ? null : reset($ownerIds); + } + + if ($ownerId === null) { + return []; + } + + $ids = $em->getConnection()->fetchFirstColumn( + "SELECT {$targetColumn} FROM {$tableName} WHERE {$sourceColumn} = ?", + [$ownerId] + ); + + return array_values(array_map('intval', $ids)); + + } catch (\Exception $e) { + Log::error("AuditEventListener::fetchManyToManyIds error: " . $e->getMessage(), [ + 'exception' => get_class($e), + 'trace' => $e->getTraceAsString() + ]); + return []; + } + } } diff --git a/app/Audit/AuditLogFormatterFactory.php b/app/Audit/AuditLogFormatterFactory.php index a66d2032b..7e00cda18 100644 --- a/app/Audit/AuditLogFormatterFactory.php +++ b/app/Audit/AuditLogFormatterFactory.php @@ -18,6 +18,8 @@ use App\Audit\ConcreteFormatters\EntityCollectionUpdateAuditLogFormatter; use App\Audit\ConcreteFormatters\EntityCreationAuditLogFormatter; use App\Audit\ConcreteFormatters\EntityDeletionAuditLogFormatter; +use App\Audit\ConcreteFormatters\DefaultEntityManyToManyCollectionUpdateAuditLogFormatter; +use App\Audit\ConcreteFormatters\DefaultEntityManyToManyCollectionDeleteAuditLogFormatter; use App\Audit\ConcreteFormatters\EntityUpdateAuditLogFormatter; use App\Audit\Interfaces\IAuditStrategy; use Doctrine\ORM\PersistentCollection; @@ -57,9 +59,7 @@ public function make(AuditContext $ctx, $subject, string $event_type): ?IAuditLo ); if (method_exists($subject, 'getTypeClass')) { $type = $subject->getTypeClass(); - // Your log shows this is ClassMetadata if ($type instanceof ClassMetadata) { - // Doctrine supports either getName() or public $name $targetEntity = method_exists($type, 'getName') ? $type->getName() : ($type->name ?? null); } elseif (is_string($type)) { $targetEntity = $type; @@ -71,7 +71,6 @@ public function make(AuditContext $ctx, $subject, string $event_type): ?IAuditLo $targetEntity = $mapping['targetEntity'] ?? null; Log::debug("AuditLogFormatterFactory::make getMapping targetEntity {$targetEntity}"); } else { - // last-resort: read private association metadata (still no hydration) $ref = new \ReflectionObject($subject); foreach (['association', 'mapping', 'associationMapping'] as $propName) { if ($ref->hasProperty($propName)) { @@ -107,6 +106,18 @@ public function make(AuditContext $ctx, $subject, string $event_type): ?IAuditLo $formatter = new EntityCollectionUpdateAuditLogFormatter($child_entity_formatter); break; + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE: + $formatter = $this->getFormatterByContext($subject, $event_type, $ctx); + if (is_null($formatter)) { + $formatter = new DefaultEntityManyToManyCollectionUpdateAuditLogFormatter(); + } + break; + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE: + $formatter = $this->getFormatterByContext($subject, $event_type, $ctx); + if (is_null($formatter)) { + $formatter = new DefaultEntityManyToManyCollectionDeleteAuditLogFormatter(); + } + break; case IAuditStrategy::EVENT_ENTITY_CREATION: $formatter = $this->getFormatterByContext($subject, $event_type, $ctx); if(is_null($formatter)) { diff --git a/app/Audit/AuditLogOtlpStrategy.php b/app/Audit/AuditLogOtlpStrategy.php index e5881b6e4..f09bfdc09 100644 --- a/app/Audit/AuditLogOtlpStrategy.php +++ b/app/Audit/AuditLogOtlpStrategy.php @@ -168,12 +168,49 @@ private function buildAuditLogData($entity, $subject, array $change_set, string case IAuditStrategy::EVENT_COLLECTION_UPDATE: if ($subject instanceof PersistentCollection) { $data['audit.collection_type'] = $this->getCollectionType($subject); - $data['audit.collection_count'] = count($subject); - - $changes = $this->getCollectionChanges($subject, $change_set); - $data['audit.collection_current_count'] = $changes['current_count']; - $data['audit.collection_snapshot_count'] = $changes['snapshot_count']; - $data['audit.collection_is_dirty'] = $changes['is_dirty'] ? 'true' : 'false'; + if ($subject->isInitialized()) { + $data['audit.collection_count'] = count($subject); + $changes = $this->getCollectionChanges($subject, $change_set); + $data['audit.collection_current_count'] = $changes['current_count']; + $data['audit.collection_snapshot_count'] = $changes['snapshot_count']; + $data['audit.collection_is_dirty'] = $changes['is_dirty'] ? 'true' : 'false'; + } else { + $data['audit.collection_count'] = 0; + $data['audit.collection_current_count'] = 0; + $data['audit.collection_snapshot_count'] = 0; + $data['audit.collection_is_dirty'] = 'true'; + } + } + break; + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE: + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE: + if (isset($change_set['collection']) && $change_set['collection'] instanceof PersistentCollection) { + $collection = $change_set['collection']; + $data['audit.collection_type'] = $this->getCollectionType($collection); + if (!empty($change_set['deleted_ids'])) { + $data['audit.collection_count'] = count($change_set['deleted_ids']); + if ($collection->isInitialized()) { + $changes = $this->getCollectionChanges($collection, $change_set); + $data['audit.collection_current_count'] = $changes['current_count']; + $data['audit.collection_snapshot_count'] = $changes['snapshot_count']; + $data['audit.collection_is_dirty'] = $changes['is_dirty'] ? 'true' : 'false'; + } else { + $data['audit.collection_current_count'] = 0; + $data['audit.collection_snapshot_count'] = 0; + $data['audit.collection_is_dirty'] = 'true'; + } + } elseif ($collection->isInitialized()) { + $data['audit.collection_count'] = count($collection); + $changes = $this->getCollectionChanges($collection, $change_set); + $data['audit.collection_current_count'] = $changes['current_count']; + $data['audit.collection_snapshot_count'] = $changes['snapshot_count']; + $data['audit.collection_is_dirty'] = $changes['is_dirty'] ? 'true' : 'false'; + } else { + $data['audit.collection_count'] = 0; + $data['audit.collection_current_count'] = 0; + $data['audit.collection_snapshot_count'] = 0; + $data['audit.collection_is_dirty'] = 'true'; + } } break; } @@ -184,17 +221,14 @@ private function buildAuditLogData($entity, $subject, array $change_set, string private function getCollectionType(PersistentCollection $collection): string { try { - if (!method_exists($collection, 'getMapping')) { - return 'unknown'; - } - + $mapping = $collection->getMapping(); + $targetEntity = $mapping->targetEntity ?? null; - if (!isset($mapping['targetEntity']) || empty($mapping['targetEntity'])) { + if (!$targetEntity) { return 'unknown'; } - - return class_basename($mapping['targetEntity']); + return class_basename($targetEntity); } catch (\Exception $ex) { return 'unknown'; } @@ -216,6 +250,8 @@ private function mapEventTypeToAction(string $event_type): string IAuditStrategy::EVENT_ENTITY_UPDATE => IAuditStrategy::ACTION_UPDATE, IAuditStrategy::EVENT_ENTITY_DELETION => IAuditStrategy::ACTION_DELETE, IAuditStrategy::EVENT_COLLECTION_UPDATE => IAuditStrategy::ACTION_COLLECTION_UPDATE, + IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE => IAuditStrategy::ACTION_COLLECTION_MANYTOMANY_UPDATE, + IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE => IAuditStrategy::ACTION_COLLECTION_MANYTOMANY_DELETE, default => IAuditStrategy::ACTION_UNKNOWN }; } @@ -227,6 +263,8 @@ private function getLogMessage(string $event_type): string IAuditStrategy::EVENT_ENTITY_UPDATE => IAuditStrategy::LOG_MESSAGE_UPDATED, IAuditStrategy::EVENT_ENTITY_DELETION => IAuditStrategy::LOG_MESSAGE_DELETED, IAuditStrategy::EVENT_COLLECTION_UPDATE => IAuditStrategy::LOG_MESSAGE_COLLECTION_UPDATED, + IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE => IAuditStrategy::LOG_MESSAGE_COLLECTION_UPDATED, + IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE => IAuditStrategy::LOG_MESSAGE_DELETED, default => IAuditStrategy::LOG_MESSAGE_CHANGED }; } diff --git a/app/Audit/ConcreteFormatters/DefaultEntityManyToManyCollectionDeleteAuditLogFormatter.php b/app/Audit/ConcreteFormatters/DefaultEntityManyToManyCollectionDeleteAuditLogFormatter.php new file mode 100644 index 000000000..00adb9f69 --- /dev/null +++ b/app/Audit/ConcreteFormatters/DefaultEntityManyToManyCollectionDeleteAuditLogFormatter.php @@ -0,0 +1,87 @@ +child_entity_formatter = $child_entity_formatter; + } + + /** + * @inheritDoc + */ + public function format($subject, array $change_set): ?string + { + try { + $metadata = $this->handleManyToManyCollection($change_set); + if ($metadata === null) { + return null; + } + + $changes = []; + + if ($this->child_entity_formatter != null && empty($metadata->preloadedDeletedIds)) { + $deleteDiff = $metadata->collection->getDeleteDiff(); + foreach ($deleteDiff as $child_changed_entity) { + $formatted = $this->child_entity_formatter + ->format($child_changed_entity, IChildEntityAuditLogFormatter::CHILD_ENTITY_DELETION); + if ($formatted !== null) { + $changes[] = $formatted; + } + } + + if (!empty($changes)) { + return implode("|", $changes); + } + } else { + $collectionData = $this->processCollection($metadata); + if ($collectionData === null) { + return null; + } + + $deletedCount = count($collectionData['removed_ids']); + if ($deletedCount === 0) { + return sprintf("Many-to-Many collection '%s' deleted: Removed IDs: []", $collectionData['field']); + } + + return self::formatManyToManyDetailedMessage($collectionData, 0, $deletedCount, 'deleted'); + } + + return null; + } catch (\Throwable $e) { + Log::error(get_class($this) . " error: " . $e->getMessage(), ['trace' => $e->getTraceAsString()]); + return null; + } + } + + +} diff --git a/app/Audit/ConcreteFormatters/DefaultEntityManyToManyCollectionUpdateAuditLogFormatter.php b/app/Audit/ConcreteFormatters/DefaultEntityManyToManyCollectionUpdateAuditLogFormatter.php new file mode 100644 index 000000000..9fc3014a5 --- /dev/null +++ b/app/Audit/ConcreteFormatters/DefaultEntityManyToManyCollectionUpdateAuditLogFormatter.php @@ -0,0 +1,96 @@ +child_entity_formatter = $child_entity_formatter; + } + + /** + * @inheritDoc + */ + public function format($subject, array $change_set): ?string + { + try { + $metadata = $this->handleManyToManyCollection($change_set); + if ($metadata === null) { + return null; + } + + $changes = []; + $insertDiff = $metadata->collection->getInsertDiff(); + $deleteDiff = $metadata->collection->getDeleteDiff(); + + if ($this->child_entity_formatter != null) { + foreach ($insertDiff as $child_changed_entity) { + $formatted = $this->child_entity_formatter + ->format($child_changed_entity, IChildEntityAuditLogFormatter::CHILD_ENTITY_CREATION); + if ($formatted !== null) { + $changes[] = $formatted; + } + } + + foreach ($deleteDiff as $child_changed_entity) { + $formatted = $this->child_entity_formatter + ->format($child_changed_entity, IChildEntityAuditLogFormatter::CHILD_ENTITY_DELETION); + if ($formatted !== null) { + $changes[] = $formatted; + } + } + + if (!empty($changes)) { + return implode("|", $changes); + } + } else { + $collectionData = $this->processCollection($metadata); + if ($collectionData === null) { + return null; + } + + $inserted_count = count($collectionData['added_ids']); + $deleted_count = count($collectionData['removed_ids']); + + if ($inserted_count > 0 || $deleted_count > 0) { + return self::formatManyToManyDetailedMessage($collectionData, $inserted_count, $deleted_count, 'updated'); + } + } + + return null; + } catch (\Throwable $e) { + Log::error(get_class($this) . " error: " . $e->getMessage()); + return null; + } + } + + +} diff --git a/app/Audit/ConcreteFormatters/PresentationCategoryGroupAuditLogFormatter.php b/app/Audit/ConcreteFormatters/PresentationCategoryGroupAuditLogFormatter.php index c96bab0d8..4ea4de910 100644 --- a/app/Audit/ConcreteFormatters/PresentationCategoryGroupAuditLogFormatter.php +++ b/app/Audit/ConcreteFormatters/PresentationCategoryGroupAuditLogFormatter.php @@ -39,7 +39,7 @@ public function format($subject, array $change_set): ?string case IAuditStrategy::EVENT_ENTITY_CREATION: return sprintf( - "Track Group (PresentationCategoryGroup) '%s' (%d) created for Summit '%s' with color '%s', max votes: %d by user %s", + "Track Group (PresentationCategoryGroup) '%s' (%s) created for Summit '%s' with color '%s', max votes: %d by user %s", $name, $id, $summit_name, @@ -51,7 +51,7 @@ public function format($subject, array $change_set): ?string case IAuditStrategy::EVENT_ENTITY_UPDATE: $change_details = $this->buildChangeDetails($change_set); return sprintf( - "Track Group (PresentationCategoryGroup) '%s' (%d) for Summit '%s' updated: %s by user %s", + "Track Group (PresentationCategoryGroup) '%s' (%s) for Summit '%s' updated: %s by user %s", $name, $id, $summit_name, @@ -61,13 +61,17 @@ public function format($subject, array $change_set): ?string case IAuditStrategy::EVENT_ENTITY_DELETION: return sprintf( - "Track Group (PresentationCategoryGroup) '%s' (%d) for Summit '%s' with color '%s' was deleted by user %s", + "Track Group (PresentationCategoryGroup) '%s' (%s) for Summit '%s' with color '%s' was deleted by user %s", $name, $id, $summit_name, $color, $this->getUserInfo() ); + + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE: + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE: + return $this->handleCategoryGroupManyToManyCollection($change_set, $id, $name, $summit_name); } } catch (\Exception $ex) { Log::warning("PresentationCategoryGroupAuditLogFormatter error: " . $ex->getMessage()); @@ -75,4 +79,59 @@ public function format($subject, array $change_set): ?string return null; } + + private function handleCategoryGroupManyToManyCollection(array $change_set, $id, $name, $summit_name): ?string + { + $metadata = $this->handleManyToManyCollection($change_set); + if ($metadata === null) { + return null; + } + + $collectionData = $this->processCollection($metadata); + if ($collectionData === null) { + return null; + } + + return $this->event_type === IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE + ? $this->formatManyToManyDelete($collectionData, $id, $name, $summit_name) + : $this->formatManyToManyUpdate($collectionData, $id, $name, $summit_name); + } + + private function formatManyToManyUpdate(array $collectionData, $id, $name, $summit_name): string + { + $field = $collectionData['field'] ?? 'unknown'; + $targetEntity = $collectionData['target_entity'] ?? 'unknown'; + $addedIds = $collectionData['added_ids'] ?? []; + $removedIds = $collectionData['removed_ids'] ?? []; + + return sprintf( + "Track Group (PresentationCategoryGroup) '%s' (%s) for Summit '%s' updated M2M (%s to %s): Added IDs: %s, Removed IDs: %s by user %s", + $name, + $id, + $summit_name, + $field, + $targetEntity, + json_encode($addedIds), + json_encode($removedIds), + $this->getUserInfo() + ); + } + + private function formatManyToManyDelete(array $collectionData, $id, $name, $summit_name): string + { + $field = $collectionData['field'] ?? 'unknown'; + $targetEntity = $collectionData['target_entity'] ?? 'unknown'; + $removedIds = $collectionData['removed_ids'] ?? []; + + return sprintf( + "Track Group (PresentationCategoryGroup) '%s' (%s) for Summit '%s' deleted M2M (%s to %s): Removed IDs: %s by user %s", + $name, + $id, + $summit_name, + $field, + $targetEntity, + json_encode($removedIds), + $this->getUserInfo() + ); + } } diff --git a/app/Audit/ConcreteFormatters/SummitAttendeeAuditLogFormatter.php b/app/Audit/ConcreteFormatters/SummitAttendeeAuditLogFormatter.php index 1e092a621..3e2a04297 100644 --- a/app/Audit/ConcreteFormatters/SummitAttendeeAuditLogFormatter.php +++ b/app/Audit/ConcreteFormatters/SummitAttendeeAuditLogFormatter.php @@ -42,11 +42,97 @@ public function format($subject, array $change_set): ?string case IAuditStrategy::EVENT_ENTITY_DELETION: return sprintf("Attendee (%s) '%s' deleted by user %s", $id, $name, $this->getUserInfo()); + + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE: + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE: + return $this->handleAttendeeManyToManyCollection($change_set, $id, $name); } - } catch (\Exception $ex) { + } catch (\Throwable $ex) { Log::warning("SummitAttendeeAuditLogFormatter error: " . $ex->getMessage()); } return null; } + + private function handleAttendeeManyToManyCollection(array $change_set, $id, $name): ?string + { + $metadata = $this->handleManyToManyCollection($change_set); + if ($metadata === null) { + return null; + } + + $collectionData = $this->processCollection($metadata); + if (!$collectionData) { + return null; + } + + return $this->event_type === IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE + ? $this->formatManyToManyDelete($collectionData, $id, $name) + : $this->formatManyToManyUpdate($collectionData, $id, $name); + } + + private function formatManyToManyUpdate(array $collectionData, $id, $name): ?string + { + try { + $field = $collectionData['field'] ?? 'unknown'; + $targetEntity = $collectionData['target_entity'] ?? 'unknown'; + $added_ids = $collectionData['added_ids'] ?? []; + $removed_ids = $collectionData['removed_ids'] ?? []; + + $idsPart = ''; + if (!empty($added_ids)) { + $idsPart .= 'Added IDs: ' . json_encode($added_ids); + } + if (!empty($removed_ids)) { + $idsPart .= (!empty($added_ids) ? ', ' : '') . 'Removed IDs: ' . json_encode($removed_ids); + } + if (empty($idsPart)) { + $idsPart = 'No changes'; + } + + $description = sprintf( + "Attendee (%s) '%s', Field: %s, Target: %s, %s, by user %s", + $id, + $name, + $field, + $targetEntity, + $idsPart, + $this->getUserInfo() + ); + + return $description; + + } catch (\Throwable $ex) { + Log::warning("SummitAttendeeAuditLogFormatter::formatManyToManyUpdate error: " . $ex->getMessage()); + return sprintf("Attendee (%s) '%s' association updated by user %s", $id, $name, $this->getUserInfo()); + } + } + + private function formatManyToManyDelete(array $collectionData, $id, $name): ?string + { + try { + $removed_ids = $collectionData['removed_ids'] ?? []; + + + + $field = $collectionData['field'] ?? 'unknown'; + $targetEntity = $collectionData['target_entity'] ?? 'unknown'; + + $description = sprintf( + "Attendee (%s) '%s' association deleted: Field: %s, Target: %s, Removed IDs: %s, by user %s", + $id, + $name, + $field, + $targetEntity, + json_encode($removed_ids), + $this->getUserInfo() + ); + + return $description; + + } catch (\Throwable $ex) { + Log::warning("SummitAttendeeAuditLogFormatter::formatManyToManyDelete error: " . $ex->getMessage()); + return null; + } + } } diff --git a/app/Audit/Interfaces/IAuditStrategy.php b/app/Audit/Interfaces/IAuditStrategy.php index 4ed0a1a9c..02971e5c9 100644 --- a/app/Audit/Interfaces/IAuditStrategy.php +++ b/app/Audit/Interfaces/IAuditStrategy.php @@ -33,12 +33,16 @@ public function audit($subject, array $change_set, string $event_type, AuditCont public const EVENT_ENTITY_CREATION = 'event_entity_creation'; public const EVENT_ENTITY_DELETION = 'event_entity_deletion'; public const EVENT_ENTITY_UPDATE = 'event_entity_update'; + public const EVENT_COLLECTION_MANYTOMANY_UPDATE = 'event_collection_manytomany_update'; + public const EVENT_COLLECTION_MANYTOMANY_DELETE = 'event_collection_manytomany_delete'; public const ACTION_CREATE = 'create'; public const ACTION_UPDATE = 'update'; public const ACTION_DELETE = 'delete'; public const ACTION_COLLECTION_UPDATE = 'collection_update'; public const ACTION_UNKNOWN = 'unknown'; + public const ACTION_COLLECTION_MANYTOMANY_UPDATE = 'collection_manytomany_update'; + public const ACTION_COLLECTION_MANYTOMANY_DELETE = 'collection_manytomany_delete'; public const LOG_MESSAGE_CREATED = 'audit.entity.created'; public const LOG_MESSAGE_UPDATED = 'audit.entity.updated'; diff --git a/app/Audit/PersistentCollectionMetadata.php b/app/Audit/PersistentCollectionMetadata.php new file mode 100644 index 000000000..5b118e6ee --- /dev/null +++ b/app/Audit/PersistentCollectionMetadata.php @@ -0,0 +1,45 @@ +getMapping(); + + return new self( + fieldName: $mapping->fieldName ?? 'unknown', + targetEntity: $mapping->targetEntity ?? 'unknown', + isInitialized: $collection->isInitialized(), + preloadedDeletedIds: $preloadedDeletedIds, + collection: $collection, + ); + } +} diff --git a/config/audit_log.php b/config/audit_log.php index 87fcae5d3..61eece282 100644 --- a/config/audit_log.php +++ b/config/audit_log.php @@ -224,6 +224,10 @@ 'enabled' => true, 'strategy' => \App\Audit\ConcreteFormatters\SummitAttendeeAuditLogFormatter::class, ], + \models\summit\PresentationCategoryGroup::class => [ + 'enabled' => true, + 'strategy' => \App\Audit\ConcreteFormatters\PresentationCategoryGroupAuditLogFormatter::class, + ], \models\summit\PresentationAttendeeVote::class => [ 'enabled' => true, 'strategy' => \App\Audit\ConcreteFormatters\PresentationAttendeeVoteAuditLogFormatter::class, diff --git a/tests/OpenTelemetry/Formatters/AllFormattersIntegrationTest.php b/tests/OpenTelemetry/Formatters/AllFormattersIntegrationTest.php index 80b6267ef..d5e0499f2 100644 --- a/tests/OpenTelemetry/Formatters/AllFormattersIntegrationTest.php +++ b/tests/OpenTelemetry/Formatters/AllFormattersIntegrationTest.php @@ -86,6 +86,8 @@ private function isMainFormatter(string $className): bool 'EntityDeletionAuditLogFormatter', 'EntityUpdateAuditLogFormatter', 'EntityCollectionUpdateAuditLogFormatter', + 'DefaultEntityManyToManyCollectionDeleteAuditLogFormatter', + 'DefaultEntityManyToManyCollectionUpdateAuditLogFormatter', ]; return !in_array($reflection->getShortName(), $genericFormatters) && @@ -138,6 +140,8 @@ public function testAllFormattersHandleAllEventTypes(): void IAuditStrategy::EVENT_ENTITY_UPDATE, IAuditStrategy::EVENT_ENTITY_DELETION, IAuditStrategy::EVENT_COLLECTION_UPDATE, + IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE, + IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE, ]; $errors = []; diff --git a/tests/OpenTelemetry/Formatters/AuditEventListenerTest.php b/tests/OpenTelemetry/Formatters/AuditEventListenerTest.php new file mode 100644 index 000000000..8d669de86 --- /dev/null +++ b/tests/OpenTelemetry/Formatters/AuditEventListenerTest.php @@ -0,0 +1,166 @@ + 'items', + 'sourceEntity' => \stdClass::class, + 'targetEntity' => \stdClass::class, + 'mappedBy' => 'owner', + 'isOwningSide' => false, + ]); + + $em = $this->createMock(EntityManagerInterface::class); + $meta = new ClassMetadata(\stdClass::class); + $collection = new PersistentCollection($em, $meta, new ArrayCollection()); + $collection->setOwner($owner, $mapping); + + $method = new \ReflectionMethod(AuditEventListener::class, 'auditCollection'); + $method->setAccessible(true); + + [$subject, $payload, $eventType] = $method->invoke( + $listener, + $collection, + new \stdClass(), + IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE + ); + + $this->assertSame($collection, $subject); + $this->assertSame([], $payload); + $this->assertSame(IAuditStrategy::EVENT_COLLECTION_UPDATE, $eventType); + } + + public function testFetchManyToManyIdsExecutesQuery(): void + { + $listener = new AuditEventListener(); + $owner = new \stdClass(); + + $mapping = ManyToManyOwningSideMapping::fromMappingArrayAndNamingStrategy([ + 'fieldName' => 'tags', + 'sourceEntity' => \stdClass::class, + 'targetEntity' => \stdClass::class, + 'isOwningSide' => true, + 'joinTable' => [ + 'name' => 'owner_tags', + 'joinColumns' => [['name' => 'owner_id', 'referencedColumnName' => 'id']], + 'inverseJoinColumns' => [['name' => 'tag_id', 'referencedColumnName' => 'id']], + ], + ], new DefaultNamingStrategy()); + + $em = $this->createMock(EntityManagerInterface::class); + $meta = new ClassMetadata(\stdClass::class); + $collection = new PersistentCollection($em, $meta, new ArrayCollection()); + $collection->setOwner($owner, $mapping); + + $meta = $this->getMockBuilder(ClassMetadata::class) + ->disableOriginalConstructor() + ->getMock(); + $meta->method('getIdentifierValues')->with($owner)->willReturn(['id' => 123]); + + $conn = $this->getMockBuilder(Connection::class) + ->disableOriginalConstructor() + ->getMock(); + $conn->method('fetchFirstColumn')->willReturn(['10', '11']); + + $em->method('getConnection')->willReturn($conn); + $em->method('getClassMetadata')->with(get_class($owner))->willReturn($meta); + + $emProp = new \ReflectionProperty(AuditEventListener::class, 'em'); + $emProp->setAccessible(true); + $emProp->setValue($listener, $em); + + $method = new \ReflectionMethod(AuditEventListener::class, 'fetchManyToManyIds'); + $method->setAccessible(true); + + $result = $method->invoke($listener, $collection, $em); + + $this->assertSame([10, 11], $result); + } + + public function testAuditCollectionDeleteInitializedWithoutDiffUsesJoinTableQuery(): void + { + $listener = new AuditEventListener(); + $owner = new \stdClass(); + + $mapping = ManyToManyOwningSideMapping::fromMappingArrayAndNamingStrategy([ + 'fieldName' => 'tags', + 'sourceEntity' => \stdClass::class, + 'targetEntity' => \stdClass::class, + 'isOwningSide' => true, + 'joinTable' => [ + 'name' => 'owner_tags', + 'joinColumns' => [['name' => 'owner_id', 'referencedColumnName' => 'id']], + 'inverseJoinColumns' => [['name' => 'tag_id', 'referencedColumnName' => 'id']], + ], + ], new DefaultNamingStrategy()); + + $em = $this->createMock(EntityManagerInterface::class); + $meta = new ClassMetadata(\stdClass::class); + $collection = new PersistentCollection($em, $meta, new ArrayCollection()); + $collection->setOwner($owner, $mapping); + $collection->takeSnapshot(); + + $ownerMeta = $this->getMockBuilder(ClassMetadata::class) + ->disableOriginalConstructor() + ->getMock(); + $ownerMeta->method('getIdentifierValues')->with($owner)->willReturn(['id' => 123]); + + $conn = $this->getMockBuilder(Connection::class) + ->disableOriginalConstructor() + ->getMock(); + $conn->method('fetchFirstColumn')->willReturn(['10', '11']); + + $em->method('getConnection')->willReturn($conn); + $em->method('getClassMetadata')->with(get_class($owner))->willReturn($ownerMeta); + + $emProp = new \ReflectionProperty(AuditEventListener::class, 'em'); + $emProp->setAccessible(true); + $emProp->setValue($listener, $em); + + $method = new \ReflectionMethod(AuditEventListener::class, 'auditCollection'); + $method->setAccessible(true); + + [$subject, $payload, $eventType] = $method->invoke( + $listener, + $collection, + new \stdClass(), + IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE + ); + + $this->assertSame($owner, $subject); + $this->assertSame([10, 11], $payload['deleted_ids']); + $this->assertSame(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE, $eventType); + } +} diff --git a/tests/OpenTelemetry/Formatters/DefaultEntityManyToManyCollectionDeleteAuditLogFormatterTest.php b/tests/OpenTelemetry/Formatters/DefaultEntityManyToManyCollectionDeleteAuditLogFormatterTest.php new file mode 100644 index 000000000..31f7e54fe --- /dev/null +++ b/tests/OpenTelemetry/Formatters/DefaultEntityManyToManyCollectionDeleteAuditLogFormatterTest.php @@ -0,0 +1,237 @@ +formatter = new DefaultEntityManyToManyCollectionDeleteAuditLogFormatter(); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function testFormatWithoutCollectionReturnsNull(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + $result = $this->formatter->format(new \stdClass(), []); + $this->assertNull($result); + } + + public function testFormatWithNullCollectionReturnsNull(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + $result = $this->formatter->format(new \stdClass(), ['collection' => null]); + $this->assertNull($result); + } + + public function testFormatWithNonPersistentCollectionReturnsNull(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + + $badCollection = new class { + public function getInsertDiff(): array + { + return []; + } + public function getDeleteDiff(): array + { + return []; + } + }; + + $result = $this->formatter->format(new \stdClass(), ['collection' => $badCollection]); + $this->assertNull($result); + } + + public function testFormatWithDeletedIdsEmptyPayloadReturnsFallbackMessage(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_REMOVE_ONE); + $result = $this->formatter->format(new \stdClass(), [ + 'collection' => $collection, + 'deleted_ids' => self::DELETED_IDS_EMPTY_PAYLOAD, + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_DELETED_PREFIX, $result); + $this->assertStringContainsString(self::LOG_REMOVED_IDS_EMPTY, $result); + } + + public function testFormatWithDeletedIdsPayloadAndNonPersistentCollectionReturnsNull(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + + $badCollection = new class { + public function getInsertDiff(): array + { + return []; + } + public function getDeleteDiff(): array + { + return []; + } + }; + + $result = $this->formatter->format(new \stdClass(), [ + 'collection' => $badCollection, + 'deleted_ids' => self::DELETED_IDS_PAYLOAD, + ]); + + $this->assertNull($result); + } + + public function testFormatWithEmptyDeleteDiffReturnsFallbackMessage(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_EMPTY); + $result = $this->formatter->format(new \stdClass(), ['collection' => $collection]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_DELETED_PREFIX, $result); + $this->assertStringContainsString(self::LOG_REMOVED_IDS_EMPTY, $result); + } + + public function testFormatWithDeletedIdsPayloadReturnsDetailedMessage(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_REMOVE_ONE); + $result = $this->formatter->format(new \stdClass(), [ + 'collection' => $collection, + 'deleted_ids' => self::DELETED_IDS_PAYLOAD, + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_DELETED_PREFIX, $result); + $this->assertStringContainsString(self::LOG_REMOVED_IDS_PAYLOAD, $result); + } + + public function testFormatWithDeletedIdsPayloadNormalizesOrder(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_REMOVE_ONE); + $result = $this->formatter->format(new \stdClass(), [ + 'collection' => $collection, + 'deleted_ids' => self::DELETED_IDS_ORDERED_PAYLOAD, + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_REMOVED_IDS_ORDERED, $result); + } + + public function testFormatWithChildFormatterUsesDeleteDiff(): void + { + $childFormatter = Mockery::mock(IChildEntityAuditLogFormatter::class); + $childFormatter + ->shouldReceive('format') + ->twice() + ->andReturn('child-1', 'child-2'); + + $formatterWithChild = new DefaultEntityManyToManyCollectionDeleteAuditLogFormatter($childFormatter); + $formatterWithChild->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_REMOVE_TWO, self::CURRENT_IDS_REMOVE_TWO); + $result = $formatterWithChild->format(new \stdClass(), ['collection' => $collection]); + + $this->assertSame('child-1|child-2', $result); + } + + public function testFormatWithChildFormatterSkipsNullMessages(): void + { + $childFormatter = Mockery::mock(IChildEntityAuditLogFormatter::class); + $childFormatter + ->shouldReceive('format') + ->twice() + ->andReturn(null, 'child-2'); + + $formatterWithChild = new DefaultEntityManyToManyCollectionDeleteAuditLogFormatter($childFormatter); + $formatterWithChild->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_REMOVE_TWO, self::CURRENT_IDS_REMOVE_TWO); + $result = $formatterWithChild->format(new \stdClass(), ['collection' => $collection]); + + $this->assertSame('child-2', $result); + } + + public function testFormatWithDeletedIdsPayloadSkipsChildFormatter(): void + { + $childFormatter = Mockery::mock(IChildEntityAuditLogFormatter::class); + $childFormatter->shouldNotReceive('format'); + + $formatterWithChild = new DefaultEntityManyToManyCollectionDeleteAuditLogFormatter($childFormatter); + $formatterWithChild->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_REMOVE_ONE); + $result = $formatterWithChild->format(new \stdClass(), [ + 'collection' => $collection, + 'deleted_ids' => self::DELETED_IDS_PAYLOAD, + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_DELETED_PREFIX, $result); + } + + private function makeCollection(array $snapshotIds, array $currentIds) + { + return PersistentCollectionTestHelper::buildManyToManyCollection( + self::ENTITY_CLASS, + self::FIELD_NAME, + self::TARGET_ENTITY, + $snapshotIds, + $currentIds + ); + } +} diff --git a/tests/OpenTelemetry/Formatters/DefaultEntityManyToManyCollectionUpdateAuditLogFormatterTest.php b/tests/OpenTelemetry/Formatters/DefaultEntityManyToManyCollectionUpdateAuditLogFormatterTest.php new file mode 100644 index 000000000..7b8f6bf0b --- /dev/null +++ b/tests/OpenTelemetry/Formatters/DefaultEntityManyToManyCollectionUpdateAuditLogFormatterTest.php @@ -0,0 +1,311 @@ +formatter = new DefaultEntityManyToManyCollectionUpdateAuditLogFormatter(); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function testFormatWithoutCollectionReturnsNull(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + $result = $this->formatter->format(new \stdClass(), []); + $this->assertNull($result); + } + + public function testFormatWithNullCollectionReturnsNull(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + $result = $this->formatter->format(new \stdClass(), ['collection' => null]); + $this->assertNull($result); + } + + public function testFormatWithNonPersistentCollectionReturnsNull(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + + $badCollection = new class { + public function getInsertDiff(): array + { + return []; + } + public function getDeleteDiff(): array + { + return []; + } + }; + + $result = $this->formatter->format(new \stdClass(), ['collection' => $badCollection]); + $this->assertNull($result); + } + + public function testFormatWithNonPersistentCollectionAndChangesReturnsNull(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + + $badCollection = new class { + public function getInsertDiff(): array + { + return [new \stdClass()]; + } + public function getDeleteDiff(): array + { + return []; + } + }; + + $result = $this->formatter->format(new \stdClass(), ['collection' => $badCollection]); + $this->assertNull($result); + } + + public function testFormatWithNoChangesReturnsNull(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_EMPTY); + $result = $this->formatter->format(new \stdClass(), ['collection' => $collection]); + + $this->assertNull($result); + } + + public function testFormatWithInsertAndDeleteDiffReturnsDetailedMessage(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_UPDATE, self::CURRENT_IDS_UPDATE); + $result = $this->formatter->format(new \stdClass(), ['collection' => $collection]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_UPDATED_PREFIX, $result); + $this->assertStringContainsString(self::LOG_ADDED_ONE, $result); + $this->assertStringContainsString(self::LOG_REMOVED_ONE, $result); + } + + public function testFormatWithOnlyAddsReturnsDetailedMessage(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_ONLY_ADDS, self::CURRENT_IDS_ONLY_ADDS); + $result = $this->formatter->format(new \stdClass(), ['collection' => $collection]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_UPDATED_PREFIX, $result); + $this->assertStringContainsString(self::LOG_ADDED_ONLY, $result); + $this->assertStringNotContainsString(self::LOG_REMOVED_ONE, $result); + } + + public function testFormatIgnoresLegacyPayloadAddedAndRemovedAndUsesCollectionDiff(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_UPDATE); + $result = $this->formatter->format(new \stdClass(), [ + 'collection' => $collection, + 'added_ids' => [2], + 'removed_ids' => [1], + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_UPDATED_PREFIX, $result); + $this->assertStringContainsString('Added 2 Tag(s): 2, 3', $result); + $this->assertStringNotContainsString('Removed 1 Tag(s): 1', $result); + } + + public function testFormatWithLegacyEmptyPayloadStillUsesCollectionDiff(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_UPDATE); + $result = $this->formatter->format(new \stdClass(), [ + 'collection' => $collection, + 'added_ids' => [], + 'removed_ids' => [], + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString('Added 2 Tag(s): 2, 3', $result); + } + + public function testFormatWithLegacyPayloadAndChildFormatterStillUsesChildDiff(): void + { + $childFormatter = Mockery::mock(IChildEntityAuditLogFormatter::class); + $childFormatter + ->shouldReceive('format') + ->twice() + ->andReturn('child-create', 'child-delete'); + + $formatterWithChild = new DefaultEntityManyToManyCollectionUpdateAuditLogFormatter($childFormatter); + $formatterWithChild->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_UPDATE); + $result = $formatterWithChild->format(new \stdClass(), [ + 'collection' => $collection, + 'added_ids' => [2], + 'removed_ids' => [1], + ]); + + $this->assertSame('child-create|child-delete', $result); + } + + public function testFormatWithOnlyDeletesReturnsDetailedMessage(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_ONLY_DELETES, self::CURRENT_IDS_ONLY_DELETES); + $result = $this->formatter->format(new \stdClass(), ['collection' => $collection]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_UPDATED_PREFIX, $result); + $this->assertStringContainsString(self::LOG_REMOVED_ONLY, $result); + $this->assertStringNotContainsString(self::LOG_ADDED_ONLY, $result); + } + + public function testFormatWithUnsortedIdsReturnsSortedMessage(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_UNSORTED, self::CURRENT_IDS_UNSORTED); + $result = $this->formatter->format(new \stdClass(), ['collection' => $collection]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_ADDED_SORTED, $result); + } + + public function testFormatWithChildFormatterUsesInsertAndDeleteDiff(): void + { + $childFormatter = Mockery::mock(IChildEntityAuditLogFormatter::class); + $childFormatter + ->shouldReceive('format') + ->twice() + ->andReturn('child-create', 'child-delete'); + + $formatterWithChild = new DefaultEntityManyToManyCollectionUpdateAuditLogFormatter($childFormatter); + $formatterWithChild->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_UPDATE, self::CURRENT_IDS_UPDATE); + $result = $formatterWithChild->format(new \stdClass(), ['collection' => $collection]); + + $this->assertSame('child-create|child-delete', $result); + } + + public function testFormatWithChildFormatterSkipsNullMessages(): void + { + $childFormatter = Mockery::mock(IChildEntityAuditLogFormatter::class); + $childFormatter + ->shouldReceive('format') + ->twice() + ->andReturn(null, 'child-delete'); + + $formatterWithChild = new DefaultEntityManyToManyCollectionUpdateAuditLogFormatter($childFormatter); + $formatterWithChild->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_UPDATE, self::CURRENT_IDS_UPDATE); + $result = $formatterWithChild->format(new \stdClass(), ['collection' => $collection]); + + $this->assertSame('child-delete', $result); + } + + public function testFormatWithChildFormatterAndNoChangesReturnsNull(): void + { + $childFormatter = Mockery::mock(IChildEntityAuditLogFormatter::class); + $childFormatter->shouldNotReceive('format'); + + $formatterWithChild = new DefaultEntityManyToManyCollectionUpdateAuditLogFormatter($childFormatter); + $formatterWithChild->setContext(AuditContextBuilder::default()->build()); + + $collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_EMPTY); + $result = $formatterWithChild->format(new \stdClass(), ['collection' => $collection]); + + $this->assertNull($result); + } + + public function testFormatterExceptionHandlingReturnsNull(): void + { + $this->formatter->setContext(AuditContextBuilder::default()->build()); + $testErrorMessage = self::TEST_ERROR_MESSAGE; + + $badCollection = new class($testErrorMessage) { + public function __construct(private string $errorMessage) {} + + public function getInsertDiff(): array + { + throw new \Exception($this->errorMessage); + } + public function getDeleteDiff(): array + { + return []; + } + }; + + $result = $this->formatter->format(new \stdClass(), ['collection' => $badCollection]); + $this->assertNull($result); + } + + private function makeCollection(array $snapshotIds, array $currentIds) + { + return PersistentCollectionTestHelper::buildManyToManyCollection( + self::ENTITY_CLASS, + self::FIELD_NAME, + self::TARGET_ENTITY, + $snapshotIds, + $currentIds + ); + } +} diff --git a/tests/OpenTelemetry/Formatters/PresentationCategoryGroupAuditLogFormatterManyToManyTest.php b/tests/OpenTelemetry/Formatters/PresentationCategoryGroupAuditLogFormatterManyToManyTest.php new file mode 100644 index 000000000..884b12c3c --- /dev/null +++ b/tests/OpenTelemetry/Formatters/PresentationCategoryGroupAuditLogFormatterManyToManyTest.php @@ -0,0 +1,195 @@ +makeFormatter($eventType, $withContext); + $group = $this->makeGroup(); + + $result = $formatter->format($group, []); + $this->assertNull($result); + } + + public function testFormatterReturnsNullForInvalidSubject(): void + { + $formatter = new PresentationCategoryGroupAuditLogFormatter( + IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE + ); + $formatter->setContext(AuditContextBuilder::default()->build()); + + $result = $formatter->format(new \stdClass(), []); + + $this->assertNull($result); + } + + public function testManyToManyDeleteReturnsEmptyRemovedIdsWhenPayloadMissing(): void + { + $group = $this->makeGroup(); + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE); + $collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_EMPTY); + + $result = $formatter->format($group, [ + 'collection' => $collection, + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_DELETED_M2M, $result); + $this->assertStringContainsString(self::LOG_REMOVED_IDS_EMPTY, $result); + } + + public function testManyToManyDeleteUsesDeletedIdsFromPayload(): void + { + $group = $this->makeGroup(); + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE); + $collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_EMPTY); + + $result = $formatter->format($group, [ + 'collection' => $collection, + 'deleted_ids' => self::DELETED_IDS_PAYLOAD, + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_REMOVED_IDS_PAYLOAD, $result); + } + + public function testManyToManyDeleteUsesRemovedIdsFromCollectionDiff(): void + { + $group = $this->makeGroup(); + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE); + $collection = $this->makeCollection(self::SNAPSHOT_IDS_REMOVE_ONE, self::CURRENT_IDS_REMOVE_ONE); + + $result = $formatter->format($group, [ + 'collection' => $collection, + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_REMOVED_IDS_DIFF, $result); + } + + public function testManyToManyUpdateUsesAddedAndRemovedIdsFromCollectionDiff(): void + { + $group = $this->makeGroup(); + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + $collection = $this->makeCollection(self::SNAPSHOT_IDS_UPDATE, self::CURRENT_IDS_UPDATE); + + $result = $formatter->format($group, [ + 'collection' => $collection, + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_UPDATED_M2M, $result); + $this->assertStringContainsString(self::LOG_ADDED_IDS_DIFF, $result); + $this->assertStringContainsString(self::LOG_REMOVED_IDS_DIFF, $result); + } + + public static function providesNullCasesForManyToMany(): array + { + return [ + self::DP_UPDATE_WITHOUT_CONTEXT => [IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE, false], + self::DP_DELETE_WITHOUT_CONTEXT => [IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE, false], + self::DP_UPDATE_WITHOUT_COLLECTION => [IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE, true], + self::DP_DELETE_WITHOUT_COLLECTION => [IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE, true], + ]; + } + + private function makeFormatter(string $eventType, bool $withContext = true): PresentationCategoryGroupAuditLogFormatter + { + $formatter = new PresentationCategoryGroupAuditLogFormatter($eventType); + if ($withContext) { + $formatter->setContext(AuditContextBuilder::default()->build()); + } + return $formatter; + } + + private function makeCollection(array $snapshotIds, array $currentIds) + { + return PersistentCollectionTestHelper::buildManyToManyCollection( + PresentationCategoryGroup::class, + self::FIELD_NAME, + self::TARGET_ENTITY, + $snapshotIds, + $currentIds + ); + } + + private function makeGroup(): PresentationCategoryGroup + { + $group = Mockery::mock(PresentationCategoryGroup::class, [ + 'getId' => self::GROUP_ID, + 'getName' => self::GROUP_NAME, + 'getColor' => self::GROUP_COLOR, + 'getMaxAttendeeVotes' => self::GROUP_MAX_ATTENDEE_VOTES, + ])->makePartial(); + + $summit = Mockery::mock(Summit::class, [ + 'getId' => self::SUMMIT_ID, + 'getName' => self::SUMMIT_NAME, + ])->makePartial(); + + $group->shouldReceive('getSummit')->andReturn($summit); + + return $group; + } +} diff --git a/tests/OpenTelemetry/Formatters/SummitAttendeeAuditLogFormatterManyToManyTest.php b/tests/OpenTelemetry/Formatters/SummitAttendeeAuditLogFormatterManyToManyTest.php new file mode 100644 index 000000000..dafd4177b --- /dev/null +++ b/tests/OpenTelemetry/Formatters/SummitAttendeeAuditLogFormatterManyToManyTest.php @@ -0,0 +1,252 @@ +makeFormatter($eventType, $withContext); + $attendee = $this->makeAttendee(); + + $result = $formatter->format($attendee, []); + $this->assertNull($result); + } + + public function testFormatterReturnsNullForInvalidSubject(): void + { + $formatter = new SummitAttendeeAuditLogFormatter( + IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE + ); + $formatter->setContext(AuditContextBuilder::default()->build()); + + $result = $formatter->format(new \stdClass(), []); + + $this->assertNull($result); + } + + public function testManyToManyDeleteReturnsMessageWithEmptyRemovedIdsWhenPayloadMissing(): void + { + $attendee = $this->makeAttendee(); + + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE); + $collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_EMPTY); + + $result = $formatter->format($attendee, [ + 'collection' => $collection, + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_REMOVED_IDS_EMPTY, $result); + } + + public function testManyToManyDeleteUsesDeletedIdsFromPayload(): void + { + $attendee = $this->makeAttendee(); + + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE); + + $deletedIds = self::DELETED_IDS_PAYLOAD; + $collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_EMPTY); + + $result = $formatter->format($attendee, [ + 'collection' => $collection, + 'deleted_ids' => $deletedIds, + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_REMOVED_IDS_PAYLOAD, $result); + } + + public function testManyToManyDeleteUsesDeletedIdsWhenSnapshotEmpty(): void + { + $attendee = $this->makeAttendee(); + + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE); + $collection = $this->makeCollection(self::SNAPSHOT_IDS_EMPTY, self::CURRENT_IDS_UPDATE); + + $result = $formatter->format($attendee, [ + 'collection' => $collection, + 'deleted_ids' => self::DELETED_IDS_FALLBACK, + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_REMOVED_IDS_FALLBACK, $result); + } + + public function testManyToManyUpdateUsesAddedAndRemovedIdsFromCollectionDiff(): void + { + $attendee = $this->makeAttendee(); + + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + $collection = $this->makeCollection(self::SNAPSHOT_IDS_UPDATE, self::CURRENT_IDS_UPDATE); + + $result = $formatter->format($attendee, [ + 'collection' => $collection, + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_ADDED_IDS_DIFF, $result); + $this->assertStringContainsString(self::LOG_REMOVED_IDS_DIFF, $result); + } + + public function testManyToManyUpdateUsesAddedIdsWhenOnlyAdds(): void + { + $attendee = $this->makeAttendee(); + + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + $collection = $this->makeCollection(self::SNAPSHOT_IDS_ONLY_ADDS, self::CURRENT_IDS_ONLY_ADDS); + + $result = $formatter->format($attendee, [ + 'collection' => $collection, + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_ADDED_IDS_ONLY, $result); + $this->assertStringNotContainsString(self::LOG_REMOVED_IDS_LABEL, $result); + } + + public function testManyToManyUpdateReturnsNoChangesWhenDiffEmpty(): void + { + $attendee = $this->makeAttendee(); + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + $collection = $this->makeCollection(self::SNAPSHOT_IDS_NO_CHANGES, self::CURRENT_IDS_NO_CHANGES); + + $result = $formatter->format($attendee, [ + 'collection' => $collection, + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_NO_CHANGES, $result); + } + + public function testManyToManyDeleteUsesRemovedIdsFromCollectionDiff(): void + { + $attendee = $this->makeAttendee(); + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE); + $collection = $this->makeCollection(self::SNAPSHOT_IDS_REMOVE_ONE, self::CURRENT_IDS_REMOVE_ONE); + + $result = $formatter->format($attendee, [ + 'collection' => $collection, + ]); + + $this->assertNotNull($result); + $this->assertStringContainsString(self::LOG_REMOVED_IDS_DIFF, $result); + } + + public static function providesNullCasesForManyToMany(): array + { + return [ + self::DP_UPDATE_WITHOUT_CONTEXT => [IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE, false], + self::DP_DELETE_WITHOUT_CONTEXT => [IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE, false], + self::DP_UPDATE_WITHOUT_COLLECTION => [IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE, true], + self::DP_DELETE_WITHOUT_COLLECTION => [IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE, true], + ]; + } + + private function makeFormatter(string $eventType, bool $withContext = true): SummitAttendeeAuditLogFormatter + { + $formatter = new SummitAttendeeAuditLogFormatter($eventType); + if ($withContext) { + $formatter->setContext(AuditContextBuilder::default()->build()); + } + return $formatter; + } + + private function makeCollection(array $snapshotIds, array $currentIds) + { + return PersistentCollectionTestHelper::buildManyToManyCollection( + SummitAttendee::class, + self::TAG_FIELD_NAME, + self::TAG_TARGET_ENTITY, + $snapshotIds, + $currentIds + ); + } + + private function makeAttendee(): SummitAttendee + { + $attendee = Mockery::mock(SummitAttendee::class, [ + 'getId' => self::ATTENDEE_ID, + 'getEmail' => self::ATTENDEE_EMAIL, + 'getFirstName' => self::ATTENDEE_FIRST_NAME, + 'getSurname' => self::ATTENDEE_SURNAME, + ])->makePartial(); + + $summit = Mockery::mock(Summit::class, [ + 'getId' => self::SUMMIT_ID, + 'getTitle' => self::SUMMIT_TITLE, + ])->makePartial(); + + $attendee->shouldReceive('getSummit')->andReturn($summit); + + return $attendee; + } + +} diff --git a/tests/OpenTelemetry/Formatters/Support/PersistentCollectionTestHelper.php b/tests/OpenTelemetry/Formatters/Support/PersistentCollectionTestHelper.php new file mode 100644 index 000000000..18eaad4a1 --- /dev/null +++ b/tests/OpenTelemetry/Formatters/Support/PersistentCollectionTestHelper.php @@ -0,0 +1,95 @@ +mapManyToMany([ + 'fieldName' => $fieldName, + 'targetEntity' => $targetEntity, + ]); + + $association = $metadata->associationMappings[$fieldName]; + + $entityPool = self::createEntityPool(array_merge($snapshotIds, $currentIds)); + + $collection = new PersistentCollection( + $entityManager, + $metadata, + new ArrayCollection(self::entitiesFromPool($snapshotIds, $entityPool)) + ); + + $collection->setOwner(new \stdClass(), $association); + $collection->takeSnapshot(); + + self::replaceCollectionItems( + $collection, + new ArrayCollection(self::entitiesFromPool($currentIds, $entityPool)) + ); + + return $collection; + } + + private static function createEntityPool(array $ids): array + { + $pool = []; + foreach (array_values(array_unique($ids)) as $id) { + $pool[$id] = new class($id) { + public function __construct(private int $id) {} + public function getId(): int + { + return $this->id; + } + }; + } + return $pool; + } + + private static function entitiesFromPool(array $ids, array $pool): array + { + $entities = []; + foreach ($ids as $id) { + if (isset($pool[$id])) { + $entities[] = $pool[$id]; + } + } + return $entities; + } + + private static function replaceCollectionItems(PersistentCollection $collection, ArrayCollection $items): void + { + $reflection = new ReflectionClass(PersistentCollection::class); + $wrapped = $reflection->getProperty('collection'); + $wrapped->setAccessible(true); + $wrapped->setValue($collection, $items); + } +} diff --git a/tests/SummitRSVPServiceTest.php b/tests/SummitRSVPServiceTest.php index 214a0b2d2..d4b9785be 100644 --- a/tests/SummitRSVPServiceTest.php +++ b/tests/SummitRSVPServiceTest.php @@ -15,9 +15,8 @@ use models\main\Member; use Illuminate\Support\Facades\Event; use App\Events\RSVP\RSVPCreated; -/** - * @covers \App\Services\Model\Imp\SummitRSVPService - */ +use PHPUnit\Framework\Attributes\CoversClass; +#[CoversClass(\App\Services\Model\Imp\SummitRSVPService::class)] class SummitRSVPServiceTest extends TestCase { use MockeryPHPUnitIntegration;