userReadRepository = $this->createMock(UserReadRepositoryInterface::class); $this->userWriteRepository = $this->createMock(UserWriteRepositoryInterface::class); $this->settingsGateway = $this->createMock(UserSettingsGateway::class); $this->auditService = $this->createMock(UserLifecycleAuditInterface::class); $this->databaseSessionRepository = $this->createMock(DatabaseSessionRepository::class); // Default: lock succeeds, release is a no-op $this->databaseSessionRepository->method('acquireAdvisoryLock')->willReturn(true); $this->databaseSessionRepository->method('releaseAdvisoryLock'); // Default: no privileged users $this->userReadRepository->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([]); } private function createService(): UserLifecycleService { return new UserLifecycleService( $this->userReadRepository, $this->userWriteRepository, $this->settingsGateway, $this->auditService, $this->databaseSessionRepository ); } public function testRunReturnsLockedWhenLockNotAcquired(): void { $this->databaseSessionRepository = $this->createMock(DatabaseSessionRepository::class); $this->databaseSessionRepository->method('acquireAdvisoryLock')->willReturn(false); $this->settingsGateway->method('getUserInactivityDeactivateDays')->willReturn(30); $this->settingsGateway->method('getUserInactivityDeleteDays')->willReturn(90); $this->userWriteRepository->expects($this->never())->method('setInactiveByIds'); $this->userWriteRepository->expects($this->never())->method('deleteByIds'); $result = $this->createService()->run(); $this->assertFalse($result['ok']); $this->assertTrue($result['locked']); $this->assertSame('lock_not_acquired', $result['error']); } public function testRunSkipsWhenDeactivateDaysIsZero(): void { $this->settingsGateway->method('getUserInactivityDeactivateDays')->willReturn(0); $this->settingsGateway->method('getUserInactivityDeleteDays')->willReturn(90); $this->userReadRepository->expects($this->never())->method('listIdsForAutoDeactivate'); $this->userReadRepository->expects($this->never())->method('listIdsForAutoDelete'); $this->userWriteRepository->expects($this->never())->method('setInactiveByIds'); $this->userWriteRepository->expects($this->never())->method('deleteByIds'); $result = $this->createService()->run(); $this->assertTrue($result['ok']); $this->assertSame(0, $result['deactivated_count']); $this->assertSame(0, $result['deleted_count']); $this->assertSame(0, $result['policy']['deactivate_days']); $this->assertSame(0, $result['policy']['delete_days']); } public function testRunDeactivatesUsers(): void { $this->settingsGateway->method('getUserInactivityDeactivateDays')->willReturn(30); $this->settingsGateway->method('getUserInactivityDeleteDays')->willReturn(0); $this->userReadRepository->method('listIdsForAutoDeactivate')->willReturn([1, 2]); $user1 = ['id' => 1, 'email' => 'a@example.com']; $user2 = ['id' => 2, 'email' => 'b@example.com']; $this->userReadRepository->method('find')->willReturnCallback( fn (int $id) => match ($id) { 1 => $user1, 2 => $user2, default => null, } ); $this->userWriteRepository->expects($this->once()) ->method('setInactiveByIds') ->with([1, 2], null) ->willReturn(2); $this->userWriteRepository->expects($this->once()) ->method('bumpAuthzVersionByUserIds') ->with([1, 2]); $this->auditService->expects($this->exactly(2))->method('logDeactivate'); $result = $this->createService()->run(); $this->assertTrue($result['ok']); $this->assertSame(2, $result['deactivated_count']); } public function testRunDeletesUsers(): void { $this->settingsGateway->method('getUserInactivityDeactivateDays')->willReturn(30); $this->settingsGateway->method('getUserInactivityDeleteDays')->willReturn(90); $this->userReadRepository->method('listIdsForAutoDeactivate')->willReturn([]); $this->userReadRepository->method('listIdsForAutoDelete')->willReturn([3]); $user3 = ['id' => 3, 'email' => 'c@example.com']; $this->userReadRepository->method('find')->willReturn($user3); $this->auditService->expects($this->once()) ->method('logDeleteWithSnapshot') ->willReturn(42); $this->userWriteRepository->expects($this->once()) ->method('deleteByIds') ->with([3]) ->willReturn(1); $result = $this->createService()->run(); $this->assertTrue($result['ok']); $this->assertSame(1, $result['deleted_count']); $this->assertSame(0, $result['skipped_count']); } public function testRunSkipsDeleteWhenSnapshotFails(): void { $this->settingsGateway->method('getUserInactivityDeactivateDays')->willReturn(30); $this->settingsGateway->method('getUserInactivityDeleteDays')->willReturn(90); $this->userReadRepository->method('listIdsForAutoDeactivate')->willReturn([]); $this->userReadRepository->method('listIdsForAutoDelete')->willReturn([5]); $user5 = ['id' => 5, 'email' => 'e@example.com']; $this->userReadRepository->method('find')->willReturn($user5); $this->auditService->expects($this->once()) ->method('logDeleteWithSnapshot') ->willReturn(0); $this->auditService->expects($this->once()) ->method('logDeleteFailure'); $this->userWriteRepository->expects($this->never())->method('deleteByIds'); $result = $this->createService()->run(); $this->assertSame(1, $result['skipped_count']); $this->assertSame(0, $result['deleted_count']); } public function testRunReleasesLockEvenOnException(): void { $this->settingsGateway->method('getUserInactivityDeactivateDays')->willReturn(30); $this->settingsGateway->method('getUserInactivityDeleteDays')->willReturn(0); $this->userReadRepository->method('listIdsForAutoDeactivate') ->willThrowException(new \RuntimeException('DB error')); $this->databaseSessionRepository = $this->createMock(DatabaseSessionRepository::class); $this->databaseSessionRepository->method('acquireAdvisoryLock')->willReturn(true); $this->databaseSessionRepository->expects($this->once())->method('releaseAdvisoryLock'); $result = $this->createService()->run(); $this->assertFalse($result['ok']); $this->assertSame('unexpected_error', $result['error']); } public function testRunReportsPrivilegedUserCount(): void { $this->userReadRepository = $this->createMock(UserReadRepositoryInterface::class); $this->userReadRepository->method('listPrivilegedUserIdsByPermissionKeys') ->willReturn([10, 20, 30]); $this->userReadRepository->method('listIdsForAutoDeactivate')->willReturn([]); $this->userReadRepository->method('listIdsForAutoDelete')->willReturn([]); $this->settingsGateway->method('getUserInactivityDeactivateDays')->willReturn(30); $this->settingsGateway->method('getUserInactivityDeleteDays')->willReturn(90); $result = $this->createService()->run(); $this->assertSame(3, $result['skipped_privileged_count']); } public function testRunTriggerTypeIsManualWhenActorProvided(): void { $this->settingsGateway->method('getUserInactivityDeactivateDays')->willReturn(30); $this->settingsGateway->method('getUserInactivityDeleteDays')->willReturn(0); $this->userReadRepository->method('listIdsForAutoDeactivate')->willReturn([7]); $user7 = ['id' => 7, 'email' => 'g@example.com']; $this->userReadRepository->method('find')->willReturn($user7); $this->userWriteRepository->method('setInactiveByIds')->willReturn(1); $this->auditService->expects($this->once()) ->method('logDeactivate') ->with( $this->isString(), 'manual', $this->isArray(), 99, $user7, 'success', null ); $result = $this->createService()->run(99); $this->assertSame(1, $result['deactivated_count']); } }