feat: add a cleanup command for accounts without recent activity
This commit is contained in:
1 parent
7ec21c1cac
commit
8a6ce7606f
3 files changed
+488
No files matched your search
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Command;
|
||||
|
||||
use App\Command\CleanupUsersCommand;
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\Role;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class CleanupUsersCommandTest extends TestCase
|
||||
{
|
||||
public function testDeletesAccountsWithoutAnEffectiveRole(): void
|
||||
{
|
||||
$customer = $this->user('[email protected]', [Role::CUSTOMER]);
|
||||
$roleless = $this->user('[email protected]', []);
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$removed = [];
|
||||
$entityManager->expects(self::exactly(2))
|
||||
->method('remove')
|
||||
->willReturnCallback(static function (User $user) use (&$removed): void {
|
||||
$removed[] = $user->getEmail();
|
||||
});
|
||||
$entityManager->expects(self::once())->method('flush');
|
||||
|
||||
$tester = new CommandTester($this->command([$customer, $roleless], $entityManager));
|
||||
$tester->execute(['--force' => true]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
self::assertSame(['[email protected]', '[email protected]'], $removed);
|
||||
self::assertStringContainsString('Deleted 2 account(s).', $tester->getDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidate query cannot read the roles, so everything the role policy protects has to
|
||||
* survive this filter — including an account whose only effective role sits beside the
|
||||
* ROLE_CUSTOMER fallback that older rows still carry.
|
||||
*
|
||||
* @param string[] $roles
|
||||
*/
|
||||
#[DataProvider('protectedRoleSets')]
|
||||
public function testKeepsAccountsHoldingAnythingBeyondTheCustomerFallback(array $roles): void
|
||||
{
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::never())->method('remove');
|
||||
|
||||
$tester = new CommandTester($this->command([$this->user('[email protected]', $roles)], $entityManager));
|
||||
$tester->execute(['--force' => true]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
self::assertStringContainsString('No accounts to remove.', $tester->getDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string[]}>
|
||||
*/
|
||||
public static function protectedRoleSets(): iterable
|
||||
{
|
||||
yield 'teamer' => [[Role::TEAMER]];
|
||||
yield 'employee' => [[Role::EMPLOYEE]];
|
||||
yield 'approved administrative role' => [[Role::TEAMER, Role::MANAGER]];
|
||||
yield 'house manager' => [[Role::HOUSE_MANAGER]];
|
||||
yield 'pending nomination awaiting approval' => [[Role::CUSTOMER, Role::pending(Role::ADMIN)]];
|
||||
yield 'stale fallback beside a real role' => [[Role::CUSTOMER, Role::TEAMER]];
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole point of the guard: a bare invocation is a preview, not a deletion. Symfony's
|
||||
* VALUE_REQUIRED only says an option needs a value when it is given, so nothing in the
|
||||
* definition ever forces the caller to state an intent — this does.
|
||||
*/
|
||||
public function testRefusesToDeleteWithoutForce(): void
|
||||
{
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::never())->method('remove');
|
||||
$entityManager->expects(self::never())->method('flush');
|
||||
|
||||
$tester = new CommandTester($this->command([$this->user('[email protected]', [Role::CUSTOMER])], $entityManager));
|
||||
$tester->execute([]);
|
||||
|
||||
self::assertSame(Command::INVALID, $tester->getStatusCode());
|
||||
self::assertStringContainsString('Would delete 1 account(s).', $tester->getDisplay());
|
||||
self::assertStringContainsString('Refusing to delete without --force', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testDryRunDeletesNothing(): void
|
||||
{
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::never())->method('remove');
|
||||
$entityManager->expects(self::never())->method('flush');
|
||||
|
||||
$tester = new CommandTester($this->command([$this->user('[email protected]', [Role::CUSTOMER])], $entityManager));
|
||||
$tester->execute(['--dry-run' => true]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
self::assertStringContainsString('Would delete 1 account(s).', $tester->getDisplay());
|
||||
self::assertStringNotContainsString('Refusing to delete', $tester->getDisplay());
|
||||
self::assertStringContainsString('[email protected]', $tester->getDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
* --limit caps the deletions, not the candidates examined, so a run that finds one deletable
|
||||
* account among protected ones still deletes it rather than spending the budget on rows it
|
||||
* then discards.
|
||||
*/
|
||||
public function testLimitCountsDeletionsRatherThanCandidates(): void
|
||||
{
|
||||
$users = [
|
||||
$this->user('[email protected]', [Role::TEAMER]),
|
||||
$this->user('[email protected]', [Role::CUSTOMER]),
|
||||
];
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::once())
|
||||
->method('remove')
|
||||
->with(self::callback(static fn (User $user): bool => '[email protected]' === $user->getEmail()));
|
||||
|
||||
$tester = new CommandTester($this->command($users, $entityManager));
|
||||
$tester->execute(['--limit' => 1, '--force' => true]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
self::assertStringContainsString('Deleted 1 account(s).', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testInactivityWindowIsOffUnlessAskedFor(): void
|
||||
{
|
||||
$repository = $this->createMock(UserRepository::class);
|
||||
$repository->expects(self::once())
|
||||
->method('findCleanupCandidates')
|
||||
->with(null)
|
||||
->willReturn([]);
|
||||
|
||||
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
|
||||
$tester->execute(['--force' => true]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testInactivityWindowIsPassedOnAsADate(): void
|
||||
{
|
||||
$repository = $this->createMock(UserRepository::class);
|
||||
$repository->expects(self::once())
|
||||
->method('findCleanupCandidates')
|
||||
->with(self::callback(static function (?\DateTimeImmutable $since): bool {
|
||||
$expected = new \DateTimeImmutable('-6 months');
|
||||
|
||||
return null !== $since && $since->format('Y-m-d') === $expected->format('Y-m-d');
|
||||
}))
|
||||
->willReturn([]);
|
||||
|
||||
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
|
||||
$tester->execute(['--inactive-for' => 6, '--force' => true]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testRejectsANonPositiveWindow(): void
|
||||
{
|
||||
$repository = $this->createMock(UserRepository::class);
|
||||
$repository->expects(self::never())->method('findCleanupCandidates');
|
||||
|
||||
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
|
||||
$tester->execute(['--inactive-for' => 0]);
|
||||
|
||||
self::assertSame(Command::INVALID, $tester->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function user(string $email, array $roles): User
|
||||
{
|
||||
return (new User($email))
|
||||
->setRoles($roles)
|
||||
->setLastLoginAt(new \DateTimeImmutable('-2 years'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param User[] $candidates
|
||||
*/
|
||||
private function command(array $candidates, EntityManagerInterface $entityManager): CleanupUsersCommand
|
||||
{
|
||||
$repository = $this->createStub(UserRepository::class);
|
||||
$repository->method('findCleanupCandidates')->willReturn($candidates);
|
||||
|
||||
return $this->commandWith($repository, $entityManager);
|
||||
}
|
||||
|
||||
private function commandWith(UserRepository $repository, EntityManagerInterface $entityManager): CleanupUsersCommand
|
||||
{
|
||||
return new CleanupUsersCommand($repository, $entityManager, $this->createStub(LoggerInterface::class));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user