diff --git a/src/DataFixtures/DataStructureFixtures.php b/src/DataFixtures/DataStructureFixtures.php index 72c57077..fc713d4d 100644 --- a/src/DataFixtures/DataStructureFixtures.php +++ b/src/DataFixtures/DataStructureFixtures.php @@ -74,30 +74,37 @@ class DataStructureFixtures extends Fixture implements DependentFixtureInterface /** @var AbstractStructuralDBElement $node1 */ $node1 = new $class(); $node1->setName('Node 1'); + $this->addReference($class . '_1', $node1); /** @var AbstractStructuralDBElement $node2 */ $node2 = new $class(); $node2->setName('Node 2'); + $this->addReference($class . '_2', $node2); /** @var AbstractStructuralDBElement $node3 */ $node3 = new $class(); $node3->setName('Node 3'); + $this->addReference($class . '_3', $node3); $node1_1 = new $class(); $node1_1->setName('Node 1.1'); $node1_1->setParent($node1); + $this->addReference($class . '_4', $node1_1); $node1_2 = new $class(); $node1_2->setName('Node 1.2'); $node1_2->setParent($node1); + $this->addReference($class . '_5', $node1_2); $node2_1 = new $class(); $node2_1->setName('Node 2.1'); $node2_1->setParent($node2); + $this->addReference($class . '_6', $node2_1); $node1_1_1 = new $class(); $node1_1_1->setName('Node 1.1.1'); $node1_1_1->setParent($node1_1); + $this->addReference($class . '_7', $node1_1_1); $manager->persist($node1); $manager->persist($node2); diff --git a/src/DataFixtures/LogEntryFixtures.php b/src/DataFixtures/LogEntryFixtures.php new file mode 100644 index 00000000..cb5d823f --- /dev/null +++ b/src/DataFixtures/LogEntryFixtures.php @@ -0,0 +1,102 @@ +. + */ + +declare(strict_types=1); + + +namespace App\DataFixtures; + +use App\Entity\LogSystem\ElementCreatedLogEntry; +use App\Entity\LogSystem\ElementDeletedLogEntry; +use App\Entity\LogSystem\ElementEditedLogEntry; +use App\Entity\Parts\Category; +use App\Entity\UserSystem\User; +use Doctrine\Bundle\FixturesBundle\Fixture; +use Doctrine\Common\DataFixtures\DependentFixtureInterface; +use Doctrine\Persistence\ObjectManager; + +class LogEntryFixtures extends Fixture implements DependentFixtureInterface +{ + + public function load(ObjectManager $manager) + { + $this->createCategoryEntries($manager); + $this->createDeletedCategory($manager); + } + + public function createCategoryEntries(ObjectManager $manager): void + { + $category = $this->getReference(Category::class . '_1', Category::class); + + $logEntry = new ElementCreatedLogEntry($category); + $logEntry->setUser($this->getReference(UserFixtures::ADMIN, User::class)); + $logEntry->setComment('Test'); + $manager->persist($logEntry); + + $logEntry = new ElementEditedLogEntry($category); + $logEntry->setUser($this->getReference(UserFixtures::ADMIN, User::class)); + $logEntry->setComment('Test'); + + $logEntry->setOldData(['name' => 'Test']); + $logEntry->setNewData(['name' => 'Node 1.1']); + + $manager->persist($logEntry); + $manager->flush(); + } + + public function createDeletedCategory(ObjectManager $manager): void + { + //We create a fictive category to test the deletion + $category = new Category(); + $category->setName('Node 100'); + + //Assume a category with id 100 was deleted + $reflClass = new \ReflectionClass($category); + $reflClass->getProperty('id')->setValue($category, 100); + + //The whole lifecycle from creation to deletion + $logEntry = new ElementCreatedLogEntry($category); + $logEntry->setUser($this->getReference(UserFixtures::ADMIN, User::class)); + $logEntry->setComment('Creation'); + $manager->persist($logEntry); + + $logEntry = new ElementEditedLogEntry($category); + $logEntry->setUser($this->getReference(UserFixtures::ADMIN, User::class)); + $logEntry->setComment('Edit'); + $logEntry->setOldData(['name' => 'Test']); + $logEntry->setNewData(['name' => 'Node 100']); + $manager->persist($logEntry); + + $logEntry = new ElementDeletedLogEntry($category); + $logEntry->setUser($this->getReference(UserFixtures::ADMIN, User::class)); + $logEntry->setOldData(['name' => 'Node 100', 'id' => 100, 'comment' => 'Test comment']); + $manager->persist($logEntry); + + $manager->flush(); + } + + public function getDependencies(): array + { + return [ + UserFixtures::class, + DataStructureFixtures::class + ]; + } +} \ No newline at end of file diff --git a/src/DataFixtures/PartFixtures.php b/src/DataFixtures/PartFixtures.php index b7193ec0..a9290cbf 100644 --- a/src/DataFixtures/PartFixtures.php +++ b/src/DataFixtures/PartFixtures.php @@ -73,6 +73,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface $part = new Part(); $part->setName('Part 1'); $part->setCategory($manager->find(Category::class, 1)); + $this->addReference(Part::class . '_1', $part); $manager->persist($part); /** More complex part */ @@ -86,6 +87,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface $part->setIpn('IPN123'); $part->setNeedsReview(true); $part->setManufacturingStatus(ManufacturingStatus::ACTIVE); + $this->addReference(Part::class . '_2', $part); $manager->persist($part); /** Part with orderdetails, storelocations and Attachments */ @@ -98,6 +100,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface $partLot1->setStorageLocation($manager->find(StorageLocation::class, 1)); $part->addPartLot($partLot1); + $partLot2 = new PartLot(); $partLot2->setExpirationDate(new \DateTimeImmutable()); $partLot2->setComment('Test'); @@ -133,6 +136,8 @@ class PartFixtures extends Fixture implements DependentFixtureInterface $attachment->setAttachmentType($manager->find(AttachmentType::class, 1)); $part->addAttachment($attachment); + $this->addReference(Part::class . '_3', $part); + $manager->persist($part); $manager->flush(); } diff --git a/src/Repository/LogEntryRepository.php b/src/Repository/LogEntryRepository.php index 40ff3ec2..daad1a81 100644 --- a/src/Repository/LogEntryRepository.php +++ b/src/Repository/LogEntryRepository.php @@ -111,7 +111,6 @@ class LogEntryRepository extends DBElementRepository { $qb = $this->createQueryBuilder('log'); $qb->select('log') - //->where('log INSTANCE OF App\Entity\LogSystem\ElementEditedLogEntry') ->where('log INSTANCE OF '.ElementEditedLogEntry::class) ->orWhere('log INSTANCE OF '.CollectionElementDeleted::class) ->andWhere('log.target_type = :target_type') @@ -143,8 +142,7 @@ class LogEntryRepository extends DBElementRepository ->andWhere('log.target_type = :target_type') ->andWhere('log.target_id = :target_id') ->andWhere('log.timestamp >= :until') - ->orderBy('log.timestamp', 'DESC') - ->groupBy('log.id'); + ; $qb->setParameter('target_type', LogTargetType::fromElementClass($element)); $qb->setParameter('target_id', $element->getID()); diff --git a/src/Services/LogSystem/TimeTravel.php b/src/Services/LogSystem/TimeTravel.php index 37209415..5b02653f 100644 --- a/src/Services/LogSystem/TimeTravel.php +++ b/src/Services/LogSystem/TimeTravel.php @@ -40,8 +40,8 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\MappingException; use Exception; use InvalidArgumentException; -use PHPUnit\Util\Type; use ReflectionClass; +use Symfony\Component\PropertyAccess\PropertyAccessor; class TimeTravel { @@ -56,8 +56,11 @@ class TimeTravel /** * Undeletes the element with the given ID. * + * @template T of AbstractDBElement * @param string $class The class name of the element that should be undeleted + * @phpstan-param class-string $class * @param int $id the ID of the element that should be undeleted + * @phpstan-return T */ public function undeleteEntity(string $class, int $id): AbstractDBElement { @@ -215,14 +218,14 @@ class TimeTravel foreach ($old_data as $field => $data) { if ($metadata->hasField($field)) { //We need to convert the string to a BigDecimal first - if (!$data instanceof BigDecimal && ('big_decimal' === $metadata->getFieldMapping($field)['type'])) { + if (!$data instanceof BigDecimal && ('big_decimal' === $metadata->getFieldMapping($field)->type)) { $data = BigDecimal::of($data); } if (!$data instanceof \DateTimeInterface - && (in_array($metadata->getFieldMapping($field)['type'], + && (in_array($metadata->getFieldMapping($field)->type, [Types::DATETIME_IMMUTABLE, Types::DATETIME_IMMUTABLE, Types::DATE_MUTABLE, Types::DATETIME_IMMUTABLE], true))) { - $data = $this->dateTimeDecode($data, $metadata->getFieldMapping($field)['type']); + $data = $this->dateTimeDecode($data, $metadata->getFieldMapping($field)->type); } $this->setField($element, $field, $data); @@ -254,8 +257,20 @@ class TimeTravel */ protected function setField(AbstractDBElement $element, string $field, mixed $new_value): void { - $reflection = new ReflectionClass($element::class); - $property = $reflection->getProperty($field); + //If the field name contains a dot, it is a embeddedable object and we need to split the field name + if (str_contains($field, '.')) { + [$embedded, $embedded_field] = explode('.', $field); + + $elementClass = new ReflectionClass($element::class); + $property = $elementClass->getProperty($embedded); + $embeddedClass = $property->getValue($element); + + $embeddedReflection = new ReflectionClass($embeddedClass::class); + $property = $embeddedReflection->getProperty($embedded_field); + } else { + $reflection = new ReflectionClass($element::class); + $property = $reflection->getProperty($field); + } //Check if the property is an BackedEnum, then convert the int or float value to an enum instance if ((is_string($new_value) || is_int($new_value)) diff --git a/tests/Repository/LogEntryRepositoryTest.php b/tests/Repository/LogEntryRepositoryTest.php new file mode 100644 index 00000000..c2329bc7 --- /dev/null +++ b/tests/Repository/LogEntryRepositoryTest.php @@ -0,0 +1,157 @@ +. + */ + +namespace App\Tests\Repository; + +use App\Entity\LogSystem\AbstractLogEntry; +use App\Entity\LogSystem\ElementEditedLogEntry; +use App\Entity\Parts\Category; +use App\Entity\Parts\Part; +use App\Entity\UserSystem\User; +use App\Repository\LogEntryRepository; +use App\Tests\Entity\LogSystem\AbstractLogEntryTest; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class LogEntryRepositoryTest extends KernelTestCase +{ + + private EntityManagerInterface $entityManager; + private LogEntryRepository $repo; + + protected function setUp(): void + { + $kernel = self::bootKernel(); + + $this->entityManager = $kernel->getContainer() + ->get('doctrine')->getManager(); + + $this->repo = $this->entityManager->getRepository(AbstractLogEntry::class); + } + + public function testFindBy(): void + { + //The findBy method should be able the target criteria and split it into the needed criterias + + $part = $this->entityManager->find(Part::class, 3); + $elements = $this->repo->findBy(['target' => $part]); + + //It should only contain one log entry, where the part was created. + $this->assertCount(1, $elements); + } + + public function testGetTargetElement(): void + { + $part = $this->entityManager->find(Part::class, 3); + $logEntry = $this->repo->findBy(['target' => $part])[0]; + + $element = $this->repo->getTargetElement($logEntry); + //The target element, must be the part we searched for + $this->assertSame($part, $element); + } + + public function testGetLastEditingUser(): void + { + //We have a edit log entry for the category with ID 1 + $category = $this->entityManager->find(Category::class, 1); + $adminUser = $this->entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + + $user = $this->repo->getLastEditingUser($category); + + //The last editing user should be the admin user + $this->assertSame($adminUser, $user); + + //For the category 2, the user must be null + $category = $this->entityManager->find(Category::class, 2); + $user = $this->repo->getLastEditingUser($category); + $this->assertNull($user); + } + + public function testGetCreatingUser(): void + { + //We have a edit log entry for the category with ID 1 + $category = $this->entityManager->find(Category::class, 1); + $adminUser = $this->entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + + $user = $this->repo->getCreatingUser($category); + + //The last editing user should be the admin user + $this->assertSame($adminUser, $user); + + //For the category 2, the user must be null + $category = $this->entityManager->find(Category::class, 2); + $user = $this->repo->getCreatingUser($category); + $this->assertNull($user); + } + + public function testGetLogsOrderedByTimestamp(): void + { + $logs = $this->repo->getLogsOrderedByTimestamp('DESC', 2, 0); + + //We have 2 log entries + $this->assertCount(2, $logs); + + //The first one must be newer than the second one + $this->assertGreaterThanOrEqual($logs[0]->getTimestamp(), $logs[1]->getTimestamp()); + } + + public function testGetElementExistedAtTimestamp(): void + { + $part = $this->entityManager->find(Part::class, 3); + + //Assume that the part is existing now + $this->assertTrue($this->repo->getElementExistedAtTimestamp($part, new \DateTimeImmutable())); + + //Assume that the part was not existing long time ago + $this->assertFalse($this->repo->getElementExistedAtTimestamp($part, new \DateTimeImmutable('2000-01-01'))); + } + + public function testGetElementHistory(): void + { + $category = $this->entityManager->find(Category::class, 1); + + $history = $this->repo->getElementHistory($category); + + //We have 4 log entries for the category + $this->assertCount(4, $history); + } + + + public function testGetTimetravelDataForElement(): void + { + $category = $this->entityManager->find(Category::class, 1); + $data = $this->repo->getTimetravelDataForElement($category, new \DateTimeImmutable('2020-01-01')); + + //The data must contain only ElementChangedLogEntry + $this->assertCount(2, $data); + $this->assertInstanceOf(ElementEditedLogEntry::class, $data[0]); + $this->assertInstanceOf(ElementEditedLogEntry::class, $data[1]); + } + + + public function testGetUndeleteDataForElement(): void + { + $undeleteData = $this->repo->getUndeleteDataForElement(Category::class, 100); + + //This must be the delete log entry we created in the fixtures + $this->assertSame('Node 100', $undeleteData->getOldName()); + } +} diff --git a/tests/Services/LogSystem/TimeTravelTest.php b/tests/Services/LogSystem/TimeTravelTest.php new file mode 100644 index 00000000..f8e9c088 --- /dev/null +++ b/tests/Services/LogSystem/TimeTravelTest.php @@ -0,0 +1,80 @@ +. + */ + +namespace App\Tests\Services\LogSystem; + +use App\Entity\LogSystem\ElementEditedLogEntry; +use App\Entity\Parts\Category; +use App\Services\LogSystem\TimeTravel; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class TimeTravelTest extends KernelTestCase +{ + + private TimeTravel $service; + private EntityManagerInterface $em; + + public function setUp(): void + { + self::bootKernel(); + $this->service = self::getContainer()->get(TimeTravel::class); + $this->em = self::getContainer()->get(EntityManagerInterface::class); + } + + public function testUndeleteEntity(): void + { + $undeletedCategory = $this->service->undeleteEntity(Category::class, 100); + + $this->assertInstanceOf(Category::class, $undeletedCategory); + $this->assertEquals(100, $undeletedCategory->getId()); + } + + public function testApplyEntry(): void + { + $category = new Category(); + //Fake an ID + $reflClass = new \ReflectionClass($category); + $reflClass->getProperty('id')->setValue($category, 1000); + + $category->setName('Test Category'); + $category->setComment('Test Comment'); + + $logEntry = new ElementEditedLogEntry($category); + $logEntry->setOldData(['name' => 'Old Category', 'comment' => 'Old Comment']); + + $this->service->applyEntry($category, $logEntry); + + $this->assertEquals('Old Category', $category->getName()); + $this->assertEquals('Old Comment', $category->getComment()); + } + + public function testRevertEntityToTimestamp(): void + { + /** @var Category $category */ + $category = $this->em->find(Category::class, 1); + + $this->service->revertEntityToTimestamp($category, new \DateTime('2022-01-01 00:00:00')); + + //The category with 1 should have the name 'Test' at this timestamp + $this->assertEquals('Test', $category->getName()); + } +}