419 lines
16 KiB
PHP
419 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
|
|
* (.ddev/plans/buspro-crm-selection-write.md). 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;
|
|
}
|
|
}
|