user('customer@example.test', [Role::CUSTOMER]); $roleless = $this->user('legacy@example.test', []); $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(['customer@example.test', 'legacy@example.test'], $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('kept@example.test', $roles)], $entityManager)); $tester->execute(['--force' => true]); self::assertSame(Command::SUCCESS, $tester->getStatusCode()); self::assertStringContainsString('No accounts to remove.', $tester->getDisplay()); } /** * @return iterable */ 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('customer@example.test', [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('customer@example.test', [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('customer@example.test', $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('teamer@example.test', [Role::TEAMER]), $this->user('customer@example.test', [Role::CUSTOMER]), ]; $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::once()) ->method('remove') ->with(self::callback(static fn (User $user): bool => 'customer@example.test' === $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)); } }