Files
myep-team/tests/Service/Teamer/TeamerProvisionerTest.php

418 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Service\Teamer;
use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\Model\ProfileResponse;
use App\BusProNet\Model\ProfileUpdateResponse;
use App\BusProNet\Model\RegistrationResponse;
use App\BusProNet\UserDataHandler;
use App\Entity\ApplicationFormSubmission;
use App\Entity\Teamer;
use App\Entity\User;
use App\Service\Teamer\TeamerProvisioner;
use App\Service\Teamer\TeamerProvisioningException;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
/**
* The three provisioning writes are not a transaction and only the first and the third can be
* rehearsed against BusPro's test database - the teamer selection is changeable in production only.
* So the orchestration is pinned here against a mocked client: what runs, in which order, what is
* persisted before the next step, and what a retry does after a failure halfway.
*/
#[AllowMockObjectsWithoutExpectations]
class TeamerProvisionerTest extends TestCase
{
private const SELECTION_ID = 1070;
private const ADDRESS_ID = 141747;
private const PERSON_ID = 224526;
private ApiClient&MockObject $apiClient;
private UserDataHandler&MockObject $userDataHandler;
protected function setUp(): void
{
$this->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('[email protected]')
->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('[email protected]', 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('[email protected]')
->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('[email protected]')
->setRoles($roles)
;
if (true === $teamer) {
$user->setTeamer((new Teamer())->setFirstName('Anna')->setLastName('Muster')->setGender('F'));
}
if (true === $deleted) {
$user->setDeletedAt(new \DateTimeImmutable());
}
return $user;
}
}