feat: soft-delete for teamer accounts

addresses #869dv9br3
This commit is contained in:
Björn Fromme
2026-08-11 12:26:13 +02:00
parent 388ecf9603
commit d654ce77fb
45 changed files with 1151 additions and 7 deletions
+67
View File
@@ -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;
};
}
}
+39
View File
@@ -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;
}
}
+38
View File
@@ -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()]);
+69
View File
@@ -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());
}
}