apiClient = $this->createMock(ApiClient::class); $this->userDataHandler = $this->createMock(UserDataHandler::class); } public function testANewApplicantBecomesATeamer(): void { $submission = $this->submission(); $this->apiClient ->expects($this->once()) ->method('createAddress') ->with($submission) ->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, true)) ; $this->expectSelectionWrite(); $this->apiClient ->expects($this->once()) ->method('resetPassword') ->with('anna@example.com') ->willReturn(new NotificationResponse()) ; $this->userDataHandler->method('findLocalUser')->willReturn(null); $this->userDataHandler ->expects($this->once()) ->method('createLocalUser') // ROLE_TEAMER needs no approval, which is the only reason it may be granted here. ->with($this->isInstanceOf(ProfileResponse::class), ['ROLE_TEAMER'], true) ->willReturn($this->user()) ; $result = $this->provisioner()->provision($submission); $this->assertTrue($result->isAddressCreated()); $this->assertTrue($result->isUserCreated()); $this->assertFalse($result->isAlreadyTeamer()); $this->assertTrue($result->isPasswordMailSent()); $this->assertSame(self::ADDRESS_ID, $submission->getBusProAddressId()); $this->assertSame(self::PERSON_ID, $submission->getBusProPersonId()); $this->assertTrue($submission->isProvisioned()); } /** * BusPro recognised the person and answered with the ids of the address it already held. The * selection is set on that record and the reviewer is told - it is not a failure, and a * duplicate is what it prevents. */ public function testAnApplicantBusProAlreadyKnowsUsesTheExistingAddress(): void { $submission = $this->submission(); $this->apiClient ->method('createAddress') ->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, false)) ; $this->expectSelectionWrite(); $this->apiClient->method('resetPassword')->willReturn(new NotificationResponse()); $this->userDataHandler->method('findLocalUser')->willReturn(null); $this->userDataHandler->method('createLocalUser')->willReturn($this->user()); $result = $this->provisioner()->provision($submission); $this->assertFalse($result->isAddressCreated()); $this->assertTrue($result->isUserCreated()); $this->assertSame(self::ADDRESS_ID, $submission->getBusProAddressId()); } /** * The step that cannot be rehearsed outside production. When BusPro refuses it, the address is * already there - so the ids have to be persisted and no local teamer may appear. */ public function testARefusedSelectionLeavesTheAddressRecordedAndNoTeamer(): void { $submission = $this->submission(); $this->apiClient ->method('createAddress') ->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, true)) ; $this->apiClient ->method('updateCrmAttribute') ->willReturn(new NotificationResponse(853, 'ID, EMail oder Passwort falsch')) ; $this->apiClient->expects($this->never())->method('resetPassword'); $this->userDataHandler->expects($this->never())->method('createLocalUser'); try { $this->provisioner()->provision($submission); $this->fail('Provisioning was expected to fail'); } catch (TeamerProvisioningException $e) { $this->assertStringContainsString('853', $e->getMessage()); } $this->assertSame(self::ADDRESS_ID, $submission->getBusProAddressId()); $this->assertFalse($submission->isProvisioned()); $this->assertNull($submission->getUser()); } /** * änderung=False is BusPro saying it did not apply the change - the selection is not * changeable in this environment, which is exactly the state of the test database. */ public function testAnUnappliedSelectionIsAFailure(): void { $submission = $this->submission(); $this->apiClient ->method('createAddress') ->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, true)) ; $this->apiClient ->method('updateCrmAttribute') ->willReturn((new ProfileUpdateResponse())->setUpdated(false)) ; $this->userDataHandler->expects($this->never())->method('createLocalUser'); $this->expectException(TeamerProvisioningException::class); $this->provisioner()->provision($submission); } /** * The retry after the failure above: the address exists and must not be created a second time. * The selection is written again instead, which is safe because that write is idempotent. */ public function testARetryResumesInsteadOfCreatingASecondAddress(): void { $submission = $this->submission() ->setBusProAddressId(self::ADDRESS_ID) ->setBusProPersonId(self::PERSON_ID) ; $this->apiClient->expects($this->never())->method('createAddress'); $this->expectSelectionWrite(); $this->apiClient->method('resetPassword')->willReturn(new NotificationResponse()); $this->userDataHandler->method('findLocalUser')->willReturn(null); $this->userDataHandler->method('createLocalUser')->willReturn($this->user()); $result = $this->provisioner()->provision($submission); $this->assertFalse($result->isAddressCreated()); $this->assertTrue($submission->isProvisioned()); } /** * Somebody who already has an account - an existing customer, or a teamer from an earlier * season. No second account, and the reviewer is told rather than left wondering why nothing * appeared. */ public function testAnApplicantWhoAlreadyHasATeamerAccountIsNotCreatedTwice(): void { $submission = $this->submission(); $user = $this->user(teamer: true, roles: ['ROLE_TEAMER']); $this->apiClient ->method('createAddress') ->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, false)) ; $this->expectSelectionWrite(); $this->apiClient->method('resetPassword')->willReturn(new NotificationResponse()); $this->userDataHandler->method('findLocalUser')->willReturn($user); $this->userDataHandler->expects($this->never())->method('createLocalUser'); $this->userDataHandler->expects($this->never())->method('grantTeamerRole'); $result = $this->provisioner()->provision($submission); $this->assertFalse($result->isUserCreated()); $this->assertTrue($result->isAlreadyTeamer()); $this->assertSame($user, $result->getUser()); $this->assertSame($user, $submission->getUser()); } /** * An account without the teamer half - an office user, or a customer who once booked a trip. * They get the role and a teamer record, not a second account. */ public function testAnAccountWithoutATeamerGetsOne(): void { $submission = $this->submission(); $user = $this->user(); $this->apiClient ->method('createAddress') ->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, false)) ; $this->expectSelectionWrite(); $this->apiClient->method('resetPassword')->willReturn(new NotificationResponse()); $this->userDataHandler->method('findLocalUser')->willReturn($user); $this->userDataHandler->expects($this->once())->method('grantTeamerRole')->with($user); $result = $this->provisioner()->provision($submission); $this->assertFalse($result->isUserCreated()); $this->assertFalse($result->isAlreadyTeamer()); $this->assertInstanceOf(Teamer::class, $user->getTeamer()); $this->assertSame('Anna', $user->getTeamer()->getFirstName()); // F/M/D is this application's own vocabulary and is stored as it came; the mapping to // BusPro's W happens on the way out only. $this->assertSame('F', $user->getTeamer()->getGender()); $this->assertSame('1990-01-01', $user->getTeamer()->getDateOfBirth()?->format('Y-m-d')); } /** * A deleted account matched by email. Granting it a role would resurrect somebody who asked to * be gone, under their old row - a decision for a human, not for a provisioning click. */ public function testADeletedAccountStopsProvisioning(): void { $submission = $this->submission(); $this->apiClient ->method('createAddress') ->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, false)) ; $this->expectSelectionWrite(); $this->userDataHandler->method('findLocalUser')->willReturn($this->user(deleted: true)); $this->userDataHandler->expects($this->never())->method('createLocalUser'); $this->apiClient->expects($this->never())->method('resetPassword'); $this->expectException(TeamerProvisioningException::class); $this->provisioner()->provision($submission); } /** * The password mail is the last step and its failure is reported, not thrown: the teamer exists * by then, and undoing three writes over an unsent mail would be worse than saying so. */ public function testAFailedPasswordMailStillLeavesAProvisionedTeamer(): void { $submission = $this->submission(); $this->apiClient ->method('createAddress') ->willReturn(new RegistrationResponse(self::ADDRESS_ID, self::PERSON_ID, true)) ; $this->expectSelectionWrite(); $this->apiClient->method('resetPassword')->willThrowException(new ApiClientException('timeout')); $this->userDataHandler->method('findLocalUser')->willReturn(null); $this->userDataHandler->method('createLocalUser')->willReturn($this->user()); $result = $this->provisioner()->provision($submission); $this->assertFalse($result->isPasswordMailSent()); $this->assertTrue($submission->isProvisioned()); } /** * A second click, or a stale page: nothing is sent to BusPro at all. */ public function testAProvisionedSubmissionIsNotProvisionedAgain(): void { $user = $this->user(); $submission = $this->submission() ->setBusProAddressId(self::ADDRESS_ID) ->setBusProPersonId(self::PERSON_ID) ->setUser($user) ->setTeamerCreatedAt(new \DateTimeImmutable()) ; $this->apiClient->expects($this->never())->method('createAddress'); $this->apiClient->expects($this->never())->method('updateCrmAttribute'); $this->apiClient->expects($this->never())->method('resetPassword'); $result = $this->provisioner()->provision($submission); $this->assertTrue($result->isAlreadyProvisioned()); $this->assertSame($user, $result->getUser()); } /** * A row from before the tightened endpoint contract. It is refused before anything is written, * because BusPro's own complaint would only arrive after the address attempt. */ public function testAnIncompleteSubmissionIsRefusedBeforeAnythingIsWritten(): void { $submission = $this->submission()->setBirthDate('01.01.1990'); $this->apiClient->expects($this->never())->method('createAddress'); try { $this->provisioner()->provision($submission); $this->fail('Provisioning was expected to fail'); } catch (TeamerProvisioningException $e) { $this->assertStringContainsString('Geburtsdatum', $e->getMessage()); } } /** * The postal address is required of every payload - street and postcode because BusPro treats * them as mandatory for a new address - so a row without it is refused here rather than at step * 1, where the reviewer would get BusPro's complaint only after the attempt. */ public function testASubmissionWithoutAnAddressIsRefusedBeforeAnythingIsWritten(): void { $submission = $this->submission()->setStreet(null)->setZipCode(null)->setCity(null); $this->apiClient->expects($this->never())->method('createAddress'); try { $this->provisioner()->provision($submission); $this->fail('Provisioning was expected to fail'); } catch (TeamerProvisioningException $e) { $this->assertStringContainsString('Straße', $e->getMessage()); $this->assertStringContainsString('Postleitzahl', $e->getMessage()); $this->assertStringContainsString('Ort', $e->getMessage()); } } private function expectSelectionWrite(): void { $this->apiClient ->expects($this->once()) ->method('updateCrmAttribute') ->with('anna@example.com', self::ADDRESS_ID, self::PERSON_ID, self::SELECTION_ID, true) ->willReturn((new ProfileUpdateResponse())->setUpdated(true)) ; } private function provisioner(): TeamerProvisioner { return new TeamerProvisioner( $this->apiClient, $this->userDataHandler, $this->createStub(EntityManagerInterface::class), new NullLogger(), self::SELECTION_ID, ); } private function submission(): ApplicationFormSubmission { return (new ApplicationFormSubmission( 'applicationform', 'Bewerbungsformular', new \DateTimeImmutable('2026-09-22T12:30:00+02:00'), 'F', )) ->setFirstName('Anna') ->setLastName('Muster') ->setEmail('anna@example.com') ->setBirthDate('1990-01-01') ->setStreet('Musterstraße 1') ->setZipCode('10115') ->setCity('Berlin') ->setEu(true) ; } /** * @param string[] $roles */ private function user(bool $teamer = false, array $roles = [], bool $deleted = false): User { $user = (new User()) ->setFirstName('Anna') ->setLastName('Muster') ->setEmail('anna@example.com') ->setRoles($roles) ; if (true === $teamer) { $user->setTeamer((new Teamer())->setFirstName('Anna')->setLastName('Muster')->setGender('F')); } if (true === $deleted) { $user->setDeletedAt(new \DateTimeImmutable()); } return $user; } }