feat: soft-delete for teamer accounts
addresses #869dv9br3
This commit is contained in:
@@ -466,6 +466,73 @@ class UserDataHandlerTest extends TestCase
|
||||
$this->assertSame(2, $user->getBusProPersonId());
|
||||
}
|
||||
|
||||
/**
|
||||
* A deleted account still has to be matched by email, otherwise the caller takes the
|
||||
* person for unknown and creates a second account for them - which would resurrect
|
||||
* them under a new row and defeat the deletion entirely. Nothing is written to it.
|
||||
*/
|
||||
public function testFindLocalUserReturnsADeletedEmailMatchWithoutWritingToIt(): void
|
||||
{
|
||||
$user = (new User())
|
||||
->setFirstName('First')
|
||||
->setLastName('Last')
|
||||
->setEmail('[email protected]')
|
||||
->setBusProAddressId(1)
|
||||
->setBusProPersonId(2)
|
||||
;
|
||||
$user->setDeleted();
|
||||
|
||||
$profileResponse = $this->createProfileResponse();
|
||||
|
||||
$repository = $this->createMock(ObjectRepository::class);
|
||||
$repository
|
||||
->expects($this->once())
|
||||
->method('findOneBy')
|
||||
->willReturn(null);
|
||||
|
||||
$repository
|
||||
->expects($this->once())
|
||||
->method('findBy')
|
||||
->with([
|
||||
'email' => '[email protected]',
|
||||
])
|
||||
->willReturn([$user]);
|
||||
|
||||
$this->entityManager
|
||||
->expects($this->once())
|
||||
->method('getRepository')
|
||||
->with(User::class)
|
||||
->willReturn($repository);
|
||||
|
||||
$handler = new UserDataHandler($this->entityManager, $this->logger);
|
||||
$resolvedUser = $handler->findLocalUser($profileResponse);
|
||||
|
||||
$this->assertSame($user, $resolvedUser);
|
||||
$this->assertSame(1, $user->getBusProAddressId());
|
||||
$this->assertSame(2, $user->getBusProPersonId());
|
||||
}
|
||||
|
||||
public function testDisableForRevokedCrmRolesLeavesADeletedAccountAlone(): void
|
||||
{
|
||||
$user = (new User())
|
||||
->setFirstName('First')
|
||||
->setLastName('Last')
|
||||
->setEmail('[email protected]')
|
||||
->setRoles(['ROLE_TEAMER'])
|
||||
;
|
||||
$user->setDeleted();
|
||||
|
||||
$this->entityManager
|
||||
->expects($this->never())
|
||||
->method('flush');
|
||||
|
||||
$handler = new UserDataHandler($this->entityManager, $this->logger);
|
||||
$handler->disableForRevokedCrmRoles($user);
|
||||
|
||||
$this->assertFalse($user->isDisabled());
|
||||
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
|
||||
}
|
||||
|
||||
private function createProfileResponse(?int $addressId = 200, ?int $personId = 100): ProfileResponse
|
||||
{
|
||||
$address = (new BusProAddress())
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Entity\Traits;
|
||||
|
||||
use App\Entity\SoftDeletableEntityInterface;
|
||||
use App\Entity\Traits\SoftDeletableEntity;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SoftDeletableEntityTest extends TestCase
|
||||
{
|
||||
public function testAFreshEntityIsNotDeleted(): void
|
||||
{
|
||||
$entity = $this->createEntity();
|
||||
|
||||
$this->assertFalse($entity->isDeleted());
|
||||
$this->assertNull($entity->getDeletedAt());
|
||||
}
|
||||
|
||||
public function testSetDeletedStampsTheTimestamp(): void
|
||||
{
|
||||
$entity = $this->createEntity();
|
||||
$entity->setDeleted();
|
||||
|
||||
$this->assertTrue($entity->isDeleted());
|
||||
$this->assertNotNull($entity->getDeletedAt());
|
||||
}
|
||||
|
||||
public function testSetRestoredClearsTheTimestamp(): void
|
||||
{
|
||||
$entity = $this->createEntity();
|
||||
$entity->setDeleted();
|
||||
$entity->setRestored();
|
||||
|
||||
$this->assertFalse($entity->isDeleted());
|
||||
$this->assertNull($entity->getDeletedAt());
|
||||
}
|
||||
|
||||
public function testDeletedAtAcceptsNullSoDeletionsCanBeUndone(): void
|
||||
{
|
||||
$entity = $this->createEntity();
|
||||
$entity->setDeletedAt(new \DateTimeImmutable());
|
||||
$entity->setDeletedAt(null);
|
||||
|
||||
$this->assertFalse($entity->isDeleted());
|
||||
}
|
||||
|
||||
private function createEntity(): SoftDeletableEntityInterface
|
||||
{
|
||||
return new class implements SoftDeletableEntityInterface {
|
||||
use SoftDeletableEntity;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\User;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Validator\ConstraintViolationListInterface;
|
||||
@@ -118,6 +119,44 @@ class UserTest extends TestCase
|
||||
$this->assertNull($enabled->getDisabledAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* A block and a deletion are independent states: setting one must never imply or
|
||||
* clear the other, so that a disciplinary block survives a deletion and the restore
|
||||
* that follows it.
|
||||
*/
|
||||
public function testDeletionAndBlockAreIndependentStates(): void
|
||||
{
|
||||
$user = new User();
|
||||
$this->assertFalse($user->isBlocked());
|
||||
|
||||
$user->setDeleted();
|
||||
$this->assertTrue($user->isBlocked());
|
||||
$this->assertFalse($user->isDisabled());
|
||||
|
||||
$user->setDisabled(true);
|
||||
$disabledAt = $user->getDisabledAt();
|
||||
|
||||
$user->setRestored();
|
||||
$this->assertFalse($user->isDeleted());
|
||||
$this->assertTrue($user->isBlocked());
|
||||
$this->assertSame($disabledAt, $user->getDisabledAt());
|
||||
|
||||
$user->setDisabled(false);
|
||||
$this->assertFalse($user->isBlocked());
|
||||
}
|
||||
|
||||
public function testSettingTheTeamerKeepsTheInverseSideInSync(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
$user = (new User())->setTeamer($teamer);
|
||||
|
||||
$this->assertSame($user, $teamer->getUser());
|
||||
|
||||
$user->setTeamer(null);
|
||||
|
||||
$this->assertNull($teamer->getUser());
|
||||
}
|
||||
|
||||
private function validate(User $user): ConstraintViolationListInterface
|
||||
{
|
||||
return Validation::createValidatorBuilder()
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\EventListener;
|
||||
|
||||
use App\Email\Mailer;
|
||||
use App\Entity\Application;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\Embeddable\Communication;
|
||||
use App\Entity\Teamer;
|
||||
use App\Event\ApplicationStatusEvent;
|
||||
use App\Event\AssignmentCalledOffEvent;
|
||||
use App\Event\DispositionCalledOffEvent;
|
||||
use App\EventListener\EmailNotificationSubscriber;
|
||||
use App\Model\ApplicationStatusDto;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Service\Pdf\ContractRenderer;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* A deleted teamer must never be mailed again - this is the point of the deletion, so
|
||||
* every handler that writes to a teamer is covered here.
|
||||
*/
|
||||
class EmailNotificationSubscriberTest extends TestCase
|
||||
{
|
||||
private Mailer&MockObject $mailer;
|
||||
private EmailNotificationSubscriber $subscriber;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->mailer = $this->createMock(Mailer::class);
|
||||
|
||||
$this->subscriber = new EmailNotificationSubscriber(
|
||||
$this->mailer,
|
||||
$this->createMock(UserRepository::class),
|
||||
$this->createMock(ContractRenderer::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function testDeletedTeamerIsNotNotifiedAboutCalledOffDisposition(): void
|
||||
{
|
||||
$disposition = $this->createDisposition($this->createDeletedTeamer());
|
||||
|
||||
$this->mailer->expects($this->never())->method('createAndSendEmail');
|
||||
|
||||
$this->subscriber->onDispositionCalledOff(new DispositionCalledOffEvent($disposition, true));
|
||||
}
|
||||
|
||||
public function testActiveTeamerIsNotifiedAboutCalledOffDisposition(): void
|
||||
{
|
||||
$disposition = $this->createDisposition($this->createTeamer());
|
||||
|
||||
$this->mailer->expects($this->once())->method('createAndSendEmail');
|
||||
|
||||
$this->subscriber->onDispositionCalledOff(new DispositionCalledOffEvent($disposition, true));
|
||||
}
|
||||
|
||||
public function testDeletedTeamerIsNotNotifiedAboutRejectedApplication(): void
|
||||
{
|
||||
$application = new Application(new Assignment(), $this->createDeletedTeamer());
|
||||
$application->setStatus(Application::STATUS_REJECTED);
|
||||
|
||||
$this->mailer->expects($this->never())->method('createAndSendEmail');
|
||||
|
||||
$this->subscriber->onApplicationStatus(
|
||||
new ApplicationStatusEvent(new ApplicationStatusDto($application))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The call-off of a whole assignment mails every teamer on it, so the exclusion has
|
||||
* to happen per teamer rather than for the event as a whole.
|
||||
*/
|
||||
public function testCalledOffAssignmentSkipsOnlyTheDeletedTeamer(): void
|
||||
{
|
||||
$assignment = new Assignment();
|
||||
$assignment->addDisposition($this->createDisposition($this->createTeamer(), $assignment));
|
||||
$assignment->addDisposition($this->createDisposition($this->createDeletedTeamer(), $assignment));
|
||||
|
||||
$this->mailer->expects($this->once())->method('createAndSendEmail');
|
||||
|
||||
$this->subscriber->onAssignmentCalledOff(new AssignmentCalledOffEvent($assignment));
|
||||
}
|
||||
|
||||
private function createDisposition(Teamer $teamer, ?Assignment $assignment = null): Disposition
|
||||
{
|
||||
return new Disposition(new Application($assignment ?? new Assignment(), $teamer));
|
||||
}
|
||||
|
||||
private function createTeamer(): Teamer
|
||||
{
|
||||
return (new Teamer())->setCommunication(
|
||||
(new Communication())->setEmail('[email protected]')
|
||||
);
|
||||
}
|
||||
|
||||
private function createDeletedTeamer(): Teamer
|
||||
{
|
||||
$teamer = $this->createTeamer();
|
||||
$teamer->setDeleted();
|
||||
|
||||
return $teamer;
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,44 @@ class BpnAuthenticatorTest extends TestCase
|
||||
$this->loadUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* The CRM must not be able to undo a deletion, in either direction: neither by
|
||||
* refreshing the account's data nor by blocking it further.
|
||||
*/
|
||||
public function testDeletedUserIsReturnedWithoutAnyCrmSync(): void
|
||||
{
|
||||
$user = (new User())->setRoles(['ROLE_TEAMER']);
|
||||
$user->setDeleted();
|
||||
|
||||
$this->stubApiClient($this->createCrmAttributes());
|
||||
|
||||
$this->userDataHandler->method('collectRoles')->willReturn(['ROLE_TEAMER']);
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn($user);
|
||||
|
||||
$this->userDataHandler->expects($this->never())->method('updateLocalUser');
|
||||
$this->userDataHandler->expects($this->never())->method('createLocalUser');
|
||||
$this->userDataHandler->expects($this->never())->method('disableForRevokedCrmRoles');
|
||||
|
||||
// returned rather than refused, so the UserChecker can explain the deletion
|
||||
$this->assertSame($user, $this->loadUser());
|
||||
}
|
||||
|
||||
public function testDeletedUserIsNotBlockedWhenTheCrmRevokedEveryRole(): void
|
||||
{
|
||||
$user = (new User())->setRoles(['ROLE_TEAMER']);
|
||||
$user->setDeleted();
|
||||
|
||||
$this->stubApiClient($this->createCrmAttributes());
|
||||
|
||||
$this->userDataHandler->method('collectRoles')->willReturn([]);
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn($user);
|
||||
|
||||
$this->userDataHandler->expects($this->never())->method('disableForRevokedCrmRoles');
|
||||
|
||||
$this->assertSame($user, $this->loadUser());
|
||||
$this->assertFalse($user->isDisabled());
|
||||
}
|
||||
|
||||
private function createCrmAttributes(): CrmAttributesResponse
|
||||
{
|
||||
return (new CrmAttributesResponse())->setAttributeGroups([new CrmAttributeGroup()]);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Security;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Security\UserChecker;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
|
||||
|
||||
class UserCheckerTest extends TestCase
|
||||
{
|
||||
private UserChecker $userChecker;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->userChecker = new UserChecker();
|
||||
}
|
||||
|
||||
public function testDeletedUserIsRefused(): void
|
||||
{
|
||||
$user = (new User())->setRoles(['ROLE_TEAMER']);
|
||||
$user->setDeleted();
|
||||
|
||||
$this->expectException(CustomUserMessageAccountStatusException::class);
|
||||
$this->expectExceptionMessageMatches('/gelöscht/');
|
||||
|
||||
$this->userChecker->checkPreAuth($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* A deletion is the stronger statement, so its message has to win over the block
|
||||
* message when an account carries both.
|
||||
*/
|
||||
public function testDeletedAndDisabledUserIsRefusedWithTheDeletionMessage(): void
|
||||
{
|
||||
$user = (new User())->setRoles(['ROLE_TEAMER']);
|
||||
$user->setDisabled(true);
|
||||
$user->setDisabledReason('Disziplinarisch gesperrt');
|
||||
$user->setDeleted();
|
||||
|
||||
$this->expectException(CustomUserMessageAccountStatusException::class);
|
||||
$this->expectExceptionMessageMatches('/gelöscht/');
|
||||
|
||||
$this->userChecker->checkPreAuth($user);
|
||||
}
|
||||
|
||||
public function testDisabledUserStillGetsTheBlockMessage(): void
|
||||
{
|
||||
$user = (new User())->setRoles(['ROLE_TEAMER']);
|
||||
$user->setDisabled(true);
|
||||
$user->setDisabledReason('Disziplinarisch gesperrt');
|
||||
|
||||
$this->expectException(CustomUserMessageAccountStatusException::class);
|
||||
$this->expectExceptionMessage('Dein Account wurde gesperrt: Disziplinarisch gesperrt');
|
||||
|
||||
$this->userChecker->checkPreAuth($user);
|
||||
}
|
||||
|
||||
public function testActiveUserWithValidRolePasses(): void
|
||||
{
|
||||
$user = (new User())->setRoles(['ROLE_TEAMER']);
|
||||
|
||||
$this->userChecker->checkPreAuth($user);
|
||||
|
||||
$this->assertFalse($user->isBlocked());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Teamer;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\User;
|
||||
use App\Service\Teamer\AccountDeletionHandler;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class AccountDeletionHandlerTest extends TestCase
|
||||
{
|
||||
private EntityManagerInterface&MockObject $entityManager;
|
||||
private AccountDeletionHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
|
||||
$this->handler = new AccountDeletionHandler(
|
||||
$this->entityManager,
|
||||
$this->createMock(LoggerInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function testDeleteFromTeamerSideFlagsBothEntities(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
$user = (new User())->setTeamer($teamer);
|
||||
|
||||
$this->entityManager->expects($this->once())->method('flush');
|
||||
|
||||
$this->handler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
|
||||
|
||||
$this->assertTrue($teamer->isDeleted());
|
||||
$this->assertTrue($user->isDeleted());
|
||||
}
|
||||
|
||||
public function testDeleteFromUserSideFlagsBothEntities(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
$user = (new User())->setTeamer($teamer);
|
||||
|
||||
$this->handler->delete($user, AccountDeletionHandler::SOURCE_SELF);
|
||||
|
||||
$this->assertTrue($teamer->isDeleted());
|
||||
$this->assertTrue($user->isDeleted());
|
||||
}
|
||||
|
||||
public function testDeleteWorksForUserWithoutTeamer(): void
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
$this->entityManager->expects($this->once())->method('flush');
|
||||
|
||||
$this->handler->delete($user, AccountDeletionHandler::SOURCE_ADMIN);
|
||||
|
||||
$this->assertTrue($user->isDeleted());
|
||||
}
|
||||
|
||||
public function testDeleteWorksForTeamerWithoutUser(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
|
||||
$this->entityManager->expects($this->once())->method('flush');
|
||||
|
||||
$this->handler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
|
||||
|
||||
$this->assertTrue($teamer->isDeleted());
|
||||
}
|
||||
|
||||
public function testDeletingTwiceIsANoOpAndKeepsTheOriginalTimestamp(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
$user = (new User())->setTeamer($teamer);
|
||||
|
||||
$this->entityManager->expects($this->once())->method('flush');
|
||||
|
||||
$this->handler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
|
||||
$deletedAt = $teamer->getDeletedAt();
|
||||
|
||||
$this->handler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
|
||||
|
||||
$this->assertSame($deletedAt, $teamer->getDeletedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* A deletion is not a block: the two states are independent, so that a disciplinary
|
||||
* block survives a deletion and the restore that follows it.
|
||||
*/
|
||||
public function testDeleteLeavesRolesAndTheBlockUntouched(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
$user = (new User())
|
||||
->setTeamer($teamer)
|
||||
->setRoles(['ROLE_TEAMER', 'ROLE_ADMIN'])
|
||||
;
|
||||
$user->setDisabled(true);
|
||||
$disabledAt = $user->getDisabledAt();
|
||||
|
||||
$this->handler->delete($user, AccountDeletionHandler::SOURCE_ADMIN);
|
||||
|
||||
$this->assertSame(['ROLE_TEAMER', 'ROLE_ADMIN'], $user->getAssignedRoles());
|
||||
$this->assertSame($disabledAt, $user->getDisabledAt());
|
||||
}
|
||||
|
||||
public function testRestoreClearsBothFlags(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
$user = (new User())->setTeamer($teamer);
|
||||
|
||||
$this->handler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
|
||||
$this->handler->restore($teamer);
|
||||
|
||||
$this->assertFalse($teamer->isDeleted());
|
||||
$this->assertFalse($user->isDeleted());
|
||||
}
|
||||
|
||||
public function testRestoreOfAnActiveAccountIsANoOp(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
|
||||
$this->entityManager->expects($this->never())->method('flush');
|
||||
|
||||
$this->handler->restore($teamer);
|
||||
|
||||
$this->assertFalse($teamer->isDeleted());
|
||||
}
|
||||
|
||||
/**
|
||||
* Should a pair ever be left half-flagged, restoring has to repair it rather than
|
||||
* skip it because one side already looks active.
|
||||
*/
|
||||
public function testRestoreRepairsAnInconsistentPair(): void
|
||||
{
|
||||
$teamer = (new Teamer())->setDeleted();
|
||||
$user = (new User())->setTeamer($teamer);
|
||||
|
||||
$this->handler->restore($user);
|
||||
|
||||
$this->assertFalse($teamer->isDeleted());
|
||||
$this->assertFalse($user->isDeleted());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user