Files
myep-team/tests/Controller/Api/Assignment/ListControllerTest.php
T

315 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Controller\Api\Assignment;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\Disposition;
use App\Entity\JobProfile;
use App\Entity\Teamer;
use App\Entity\Upload;
use App\Security\AccessToken\ApiKeyExtractor;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* The n8n placements feed against real rows in db_test: which assignments it selects, and what a
* place row says. How places are numbered is pinned in AssignmentTest.
*
* Every test runs inside a transaction that is rolled back, so db_test - shared between branches -
* keeps nothing. The job profile is inserted under the id the test environment configures for the
* feed (services.yaml, when@test).
*/
class ListControllerTest extends WebTestCase
{
private const string KEY = 'test-n8n-api-key';
private const string APPLICATION_FORM_KEY = 'test-application-form-api-key';
private const int JOB_PROFILE_ID = 900001;
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
private JobProfile $jobProfile;
protected function setUp(): void
{
// access_control requires https for every path, so an http request is a 301 here
$this->client = static::createClient(server: ['HTTPS' => true]);
$this->client->disableReboot();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->entityManager->getConnection()->beginTransaction();
$this->entityManager->getConnection()->insert('job_profile', [
'id' => self::JOB_PROFILE_ID,
'uuid' => 'a7c3f0de-0000-4000-8000-000000900001',
'name' => 'Reiseleitung',
'required_licenses' => '[]',
'created_at' => '2026-01-01 00:00:00',
]);
$this->jobProfile = $this->entityManager->find(JobProfile::class, self::JOB_PROFILE_ID);
}
protected function tearDown(): void
{
$this->entityManager->getConnection()->rollBack();
parent::tearDown();
}
public function testAKeylessRequestIsRejected(): void
{
$this->client->request('GET', '/api/einsaetze?von=2026-12-01&bis=2026-12-31');
$this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode());
}
public function testAWrongKeyIsRejected(): void
{
$this->get('von=2026-12-01&bis=2026-12-31', 'wrong-key');
$this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode());
}
/**
* The websites' key must not read placements - it names people.
*/
public function testTheApplicationFormKeyIsForbidden(): void
{
$this->get('von=2026-12-01&bis=2026-12-31', self::APPLICATION_FORM_KEY);
$this->assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
}
public function testARequestWithoutARangeIsRejectedNamingBothDates(): void
{
$this->get('');
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
$this->assertEqualsCanonicalizing(['von', 'bis'], array_column($this->decode()['errors'], 'field'));
}
public function testAnUnusableDateIsRejected(): void
{
$this->get('von=01.12.2026&bis=2026-12-31');
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
$this->assertContains('von must be a date as YYYY-MM-DD.', array_column($this->decode()['errors'], 'message'));
}
public function testAnInvertedRangeIsRejected(): void
{
$this->get('von=2026-12-31&bis=2026-12-01');
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
$this->assertSame(['bis must not be before von.'], array_column($this->decode()['errors'], 'message'));
}
public function testAFilledPlaceNamesThePerson(): void
{
$assignment = $this->assignment();
$teamer = $this->teamer(dateOfBirth: '2005-03-01');
$disposition = $this->dispose($assignment, $teamer)->setStatus(Disposition::STATUS_CONFIRMED);
$this->contract($disposition, Upload::STATUS_CHECKED);
$this->entityManager->flush();
$this->get('von=2026-12-01&bis=2026-12-31');
$this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$this->assertSame([[
'platz_id' => $assignment->getId().'-1',
'einsatz_id' => $assignment->getId(),
'person_id' => $teamer->getId(),
'vorname' => 'Kim',
'nachname' => 'Muster',
'haus' => 'DPW',
'von' => '2026-12-05',
'bis' => '2026-12-12',
'jobprofil_id' => self::JOB_PROFILE_ID,
'jobprofil' => 'Reiseleitung',
'status' => 'fix',
'unter_26' => true,
'vertrag_liegt_vor' => true,
'reise_code' => 'DPWBSP051226',
]], $this->decode());
}
/**
* Only the person drops out, so the place stays - open, under the same number.
*/
public function testACalledOffTeamerLeavesAnOpenPlace(): void
{
$assignment = $this->assignment(places: 2);
$calledOff = $this->dispose($assignment, $this->teamer());
$stayer = $this->dispose($assignment, $this->teamer());
$calledOff->setStatus(Disposition::STATUS_CALLED_OFF);
$this->entityManager->flush();
$this->get('von=2026-12-01&bis=2026-12-31');
$rows = $this->decode();
$this->assertSame([$assignment->getId().'-1', $assignment->getId().'-2'], array_column($rows, 'platz_id'));
$this->assertSame([null, $stayer->getTeamer()->getId()], array_column($rows, 'person_id'));
$this->assertSame(['offen', 'angefragt'], array_column($rows, 'status'));
$this->assertSame([null, null], array_column($rows, 'unter_26'));
$this->assertSame([null, false], array_column($rows, 'vertrag_liegt_vor'));
}
/**
* An uploaded contract the office has not checked yet does not count.
*/
public function testAnUncheckedContractIsNotThere(): void
{
$disposition = $this->dispose($this->assignment(), $this->teamer())->setStatus(Disposition::STATUS_CHECKING_CONTRACT);
$this->contract($disposition, Upload::STATUS_PENDING);
$this->entityManager->flush();
$this->get('von=2026-12-01&bis=2026-12-31');
$this->assertSame('vertrag', $this->decode()[0]['status']);
$this->assertFalse($this->decode()[0]['vertrag_liegt_vor']);
}
public function testASkipFormalitiesAssignmentHasNoContractToHave(): void
{
$this->dispose($this->assignment()->setSkipFormalities(true), $this->teamer());
$this->entityManager->flush();
$this->get('von=2026-12-01&bis=2026-12-31');
$this->assertNull($this->decode()[0]['vertrag_liegt_vor']);
}
/**
* 26 on the first day is no longer under 26.
*/
public function testUnder26IsTakenOnTheFirstDay(): void
{
$this->dispose($this->assignment(), $this->teamer(dateOfBirth: '2000-12-05'));
$this->dispose($this->assignment(), $this->teamer(dateOfBirth: '2000-12-06'));
$this->entityManager->flush();
$this->get('von=2026-12-01&bis=2026-12-31');
$this->assertSame([false, true], array_column($this->decode(), 'unter_26'));
}
public function testAnAssignmentOverlappingEitherEdgeIsIncludedAndOneOutsideIsNot(): void
{
$startsBefore = $this->assignment(from: '2026-11-28', to: '2026-12-01');
$endsAfter = $this->assignment(from: '2026-12-31', to: '2027-01-03');
$this->assignment(from: '2026-11-20', to: '2026-11-30');
$this->assignment(from: '2027-01-01', to: '2027-01-08');
$this->entityManager->flush();
$this->get('von=2026-12-01&bis=2026-12-31');
$this->assertSame([$startsBefore->getId(), $endsAfter->getId()], array_column($this->decode(), 'einsatz_id'));
}
public function testOnlyPublishedAssignmentsOfTheHousesAndJobProfilesAreIncluded(): void
{
$included = $this->assignment();
$this->assignment(hotelCode: 'SSLXYZ');
$this->assignment()->setStatus(Assignment::STATUS_CALLED_OFF);
$this->assignment()->setStatus(Assignment::STATUS_DRAFT);
$this->assignment()->setDeletedAt(new \DateTimeImmutable());
$otherProfile = (new JobProfile())->setName('Koch/Köchin');
$this->entityManager->persist($otherProfile);
$this->assignment()->setJobProfile($otherProfile);
$this->entityManager->flush();
$this->get('von=2026-12-01&bis=2026-12-31');
$this->assertSame([$included->getId()], array_column($this->decode(), 'einsatz_id'));
}
private function assignment(string $from = '2026-12-05', string $to = '2026-12-12', string $hotelCode = 'DPWGRU1', int $places = 1): Assignment
{
$destination = (new Destination())
->setProduct('Davos - Waldschlössli')
->setHotel('Waldschlössli')
->setHotelCode($hotelCode)
->setHotelBusProId(1)
->setCode('DPWBSP/051226')
->setDateFrom(new \DateTimeImmutable($from))
->setDateTo(new \DateTimeImmutable($to))
->setPickups([])
;
$assignment = (new Assignment())
->setDestination($destination)
->setJobProfile($this->jobProfile)
->setAvailableDispositions($places)
->setStatus(Assignment::STATUS_PUBLISHED)
;
$this->entityManager->persist($destination);
$this->entityManager->persist($assignment);
return $assignment;
}
private function teamer(?string $dateOfBirth = null): Teamer
{
$teamer = (new Teamer())
->setFirstName('Kim')
->setLastName('Muster')
->setGender('d')
->setDateOfBirth(null === $dateOfBirth ? null : new \DateTimeImmutable($dateOfBirth))
;
$this->entityManager->persist($teamer);
return $teamer;
}
private function dispose(Assignment $assignment, Teamer $teamer): Disposition
{
$disposition = new Disposition(new Application($assignment, $teamer));
$assignment->addDisposition($disposition);
$this->entityManager->persist($disposition);
return $disposition;
}
private function contract(Disposition $disposition, string $status): void
{
$upload = (new Upload())
->setType(Upload::TYPE_CONTRACT)
->setStatus($status)
->setFilename('vertrag.pdf')
->setOriginalFilename('vertrag.pdf')
->setMimeType('application/pdf')
->setSize(1024)
;
$disposition->addDocument($upload);
$this->entityManager->persist($upload);
}
/**
* Clears the entity manager first, so the feed hydrates from the database rather than handing
* back the collections this test built in memory.
*/
private function get(string $query, string $key = self::KEY): void
{
$this->entityManager->clear();
$this->client->request('GET', '/api/einsaetze?'.$query, server: [
'HTTP_'.strtoupper(str_replace('-', '_', ApiKeyExtractor::HEADER)) => $key,
]);
}
/**
* @return array<mixed>
*/
private function decode(): array
{
return json_decode((string) $this->client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR);
}
}