entityManager = $this->createMock(EntityManagerInterface::class); $this->logger = $this->createMock(LoggerInterface::class); } /** * @dataProvider collectRolesProvider */ public function testCollectRolesImportsAdministrativeRolesAsPendingOnly(CrmAttributesResponse $crmAttributes, array $expectedRoles): void { $handler = new UserDataHandler($this->entityManager, $this->logger); $this->assertSame($expectedRoles, $handler->collectRoles($crmAttributes)); } public static function collectRolesProvider(): iterable { yield 'admin only yields the pending marker' => [ (new CrmAttributesResponse())->setAdmin(true), [User::PENDING_ROLES['ROLE_ADMIN']], ]; yield 'manager only yields the pending marker' => [ (new CrmAttributesResponse())->setManager(true), [User::PENDING_ROLES['ROLE_MANAGER']], ]; yield 'house manager only yields the pending marker' => [ (new CrmAttributesResponse())->setHouseManager(true), [User::PENDING_ROLES['ROLE_HOUSE_MANAGER']], ]; yield 'teamer is granted directly' => [ (new CrmAttributesResponse())->setTeamer(true), ['ROLE_TEAMER'], ]; yield 'admin and teamer' => [ (new CrmAttributesResponse())->setAdmin(true)->setTeamer(true), [User::PENDING_ROLES['ROLE_ADMIN'], 'ROLE_TEAMER'], ]; yield 'admin and manager yield both markers' => [ (new CrmAttributesResponse())->setAdmin(true)->setManager(true), [User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_MANAGER']], ]; yield 'manager takes precedence over house manager' => [ (new CrmAttributesResponse())->setManager(true)->setHouseManager(true), [User::PENDING_ROLES['ROLE_MANAGER']], ]; yield 'house manager and teamer' => [ (new CrmAttributesResponse())->setHouseManager(true)->setTeamer(true), [User::PENDING_ROLES['ROLE_HOUSE_MANAGER'], 'ROLE_TEAMER'], ]; } public function testUpdateLocalUserSyncsUserAndTeamerDataFromBusPro(): void { $user = (new User()) ->setFirstName('Old') ->setLastName('Name') ->setEmail('old@example.com') ->setBusProAddressId(1) ->setBusProPersonId(2) ->setRoles(['ROLE_ADMIN']) ->setHotelCodes(['XYZ']) ; $teamer = (new Teamer()) ->setFirstName('OldTeamer') ->setLastName('OldLastname') ->setAcademicTitle('Dr.') ->setSalutation('Herr') ->setGender('M') ->setDateOfBirth(new \DateTimeImmutable('1990-01-01')) ->setAddress((new Address())->setStreet('Old Street')->setPostCode('11111')->setCity('Old City')->setCountry('DE')) ->setCommunication((new Communication())->setPhone('123')->setMobile('456')->setEmail('old@example.com')) ; $user->setTeamer($teamer); $profileResponse = $this->createProfileResponse(); $this->entityManager ->expects($this->once()) ->method('flush'); $handler = new UserDataHandler($this->entityManager, $this->logger); $handler->updateLocalUser( $user, $profileResponse, true, ['team' => ['selected' => true]], ); $this->assertSame('New', $user->getFirstName()); $this->assertSame('Lastname', $user->getLastName()); $this->assertSame('new@example.com', $user->getEmail()); // roles and hotel codes are imported on creation only and stay under manual control $this->assertSame(['XYZ'], $user->getHotelCodes()); $this->assertTrue($user->hasRole('ROLE_ADMIN')); $this->assertSame('New', $teamer->getFirstName()); $this->assertSame('Lastname', $teamer->getLastName()); $this->assertSame('Prof.', $teamer->getAcademicTitle()); $this->assertSame('Frau', $teamer->getSalutation()); $this->assertSame('F', $teamer->getGender()); $this->assertEquals(new \DateTimeImmutable('1995-12-24'), $teamer->getDateOfBirth()); $this->assertSame('New Street 123', $teamer->getAddress()?->getStreet()); $this->assertSame('54321', $teamer->getAddress()?->getPostCode()); $this->assertSame('New City', $teamer->getAddress()?->getCity()); $this->assertSame('AT', $teamer->getAddress()?->getCountry()); $this->assertSame('999', $teamer->getCommunication()?->getPhone()); $this->assertSame('888', $teamer->getCommunication()?->getMobile()); $this->assertSame('new@example.com', $teamer->getCommunication()?->getEmail()); $this->assertSame(['team' => ['selected' => true]], $teamer->getCrmSelections()); } /** * @dataProvider pendingRolesProvider */ public function testUpdateLocalUserRefreshesThePendingRoles( array $roles, array $claimedRoles, array $expectedAssignedRoles, array $expectedPendingRoles, ): void { $user = (new User()) ->setFirstName('Old') ->setLastName('Name') ->setEmail('old@example.com') ->setRoles($roles) ; $handler = new UserDataHandler($this->entityManager, $this->logger); $handler->updateLocalUser($user, $this->createProfileResponse(), false, [], $claimedRoles); $this->assertSame($expectedAssignedRoles, $user->getAssignedRoles()); $this->assertSame($expectedPendingRoles, $user->getPendingRoles()); } public static function pendingRolesProvider(): iterable { yield 'marker is added when the CRM claims a manager' => [ ['ROLE_TEAMER'], [User::PENDING_ROLES['ROLE_MANAGER']], ['ROLE_TEAMER'], [User::PENDING_ROLES['ROLE_MANAGER']], ]; yield 'marker is dropped when the CRM attribute is gone' => [ [User::PENDING_ROLES['ROLE_HOUSE_MANAGER'], 'ROLE_TEAMER'], [], ['ROLE_TEAMER'], [], ]; yield 'an approved role is never marked again' => [ ['ROLE_MANAGER'], [User::PENDING_ROLES['ROLE_MANAGER']], ['ROLE_MANAGER'], [], ]; yield 'a claim beyond the approved role stays pending' => [ ['ROLE_MANAGER'], [User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_MANAGER']], ['ROLE_MANAGER'], [User::PENDING_ROLES['ROLE_ADMIN']], ]; yield 'the claimed role changes' => [ [User::PENDING_ROLES['ROLE_HOUSE_MANAGER']], [User::PENDING_ROLES['ROLE_MANAGER']], [], [User::PENDING_ROLES['ROLE_MANAGER']], ]; yield 'granted roles are untouched without any claim' => [ ['ROLE_ADMIN'], [], ['ROLE_ADMIN'], [], ]; } public function testFindLocalUserFallsBackToUniqueEmailAndRefreshesBusProIds(): void { $user = (new User()) ->setFirstName('First') ->setLastName('Last') ->setEmail('new@example.com') ->setBusProAddressId(1) ->setBusProPersonId(2) ; $profileResponse = $this->createProfileResponse(); $repository = $this->createMock(ObjectRepository::class); $repository ->expects($this->once()) ->method('findOneBy') ->with([ 'busProAddressId' => 200, 'busProPersonId' => 100, ]) ->willReturn(null); $repository ->expects($this->once()) ->method('findBy') ->with([ 'email' => 'new@example.com', ]) ->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(200, $user->getBusProAddressId()); $this->assertSame(100, $user->getBusProPersonId()); } public function testFindLocalUserSkipsEmailFallbackWhenMultipleUsersExist(): void { $userA = (new User()) ->setFirstName('A') ->setLastName('A') ->setEmail('new@example.com') ->setBusProAddressId(1) ->setBusProPersonId(2) ; $userB = (new User()) ->setFirstName('B') ->setLastName('B') ->setEmail('new@example.com') ->setBusProAddressId(3) ->setBusProPersonId(4) ; $profileResponse = $this->createProfileResponse(); $repository = $this->createMock(ObjectRepository::class); $repository ->expects($this->once()) ->method('findOneBy') ->willReturn(null); $repository ->expects($this->once()) ->method('findBy') ->willReturn([$userA, $userB]); $this->entityManager ->expects($this->once()) ->method('getRepository') ->with(User::class) ->willReturn($repository); $this->logger ->expects($this->once()) ->method('warning') ->with( 'Unable to match local user by email: multiple users found', [ 'email' => 'new@example.com', 'count' => 2, ], ); $handler = new UserDataHandler($this->entityManager, $this->logger); $resolvedUser = $handler->findLocalUser($profileResponse); $this->assertNull($resolvedUser); } public function testFindLocalUserReturnsUniqueEmailMatchWhenBusProIdsAreMissing(): void { $user = (new User()) ->setFirstName('First') ->setLastName('Last') ->setEmail('new@example.com') ->setBusProAddressId(1) ->setBusProPersonId(2) ; $profileResponse = $this->createProfileResponse(null, null); $repository = $this->createMock(ObjectRepository::class); $repository ->expects($this->once()) ->method('findOneBy') ->with([ 'busProAddressId' => null, 'busProPersonId' => null, ]) ->willReturn(null); $repository ->expects($this->once()) ->method('findBy') ->with([ 'email' => 'new@example.com', ]) ->willReturn([$user]); $this->entityManager ->expects($this->once()) ->method('getRepository') ->with(User::class) ->willReturn($repository); $this->logger ->expects($this->once()) ->method('warning') ->with( 'Skip BusPro ID refresh: profile response missing IDs', [ 'user_id' => null, 'user_email' => 'new@example.com', 'bus_pro_address_id' => null, 'bus_pro_person_id' => null, ], ); $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()); } private function createProfileResponse(?int $addressId = 200, ?int $personId = 100): ProfileResponse { $address = (new BusProAddress()) ->setStreet('New Street 123') ->setPostCode('54321') ->setCity('New City') ->setCountry('AT') ; $communication = (new BusProCommunication()) ->setPhone('999') ->setMobile('888') ->setEmail('new@example.com') ; return (new ProfileResponse()) ->setAddressId($addressId) ->setPersonId($personId) ->setFirstName('New') ->setName('Lastname') ->setTitle('Prof.') ->setSalutation('Frau') ->setGender('F') ->setDateOfBirth(new \DateTimeImmutable('1995-12-24')) ->setAddress($address) ->setCommunication($communication) ; } }