315 lines
12 KiB
PHP
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 assignments 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/assignments?from=2026-12-01&to=2026-12-31');
|
|
|
|
$this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode());
|
|
}
|
|
|
|
public function testAWrongKeyIsRejected(): void
|
|
{
|
|
$this->get('from=2026-12-01&to=2026-12-31', 'wrong-key');
|
|
|
|
$this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode());
|
|
}
|
|
|
|
/**
|
|
* The websites' key must not read the assignments feed - it names people.
|
|
*/
|
|
public function testTheApplicationFormKeyIsForbidden(): void
|
|
{
|
|
$this->get('from=2026-12-01&to=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(['from', 'to'], array_column($this->decode()['errors'], 'field'));
|
|
}
|
|
|
|
public function testAnUnusableDateIsRejected(): void
|
|
{
|
|
$this->get('from=01.12.2026&to=2026-12-31');
|
|
|
|
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
|
$this->assertContains('from must be a date as YYYY-MM-DD.', array_column($this->decode()['errors'], 'message'));
|
|
}
|
|
|
|
public function testAnInvertedRangeIsRejected(): void
|
|
{
|
|
$this->get('from=2026-12-31&to=2026-12-01');
|
|
|
|
$this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode());
|
|
$this->assertSame(['to must not be before from.'], 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('from=2026-12-01&to=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('from=2026-12-01&to=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('from=2026-12-01&to=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('from=2026-12-01&to=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('from=2026-12-01&to=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('from=2026-12-01&to=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('from=2026-12-01&to=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/assignments?'.$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);
|
|
}
|
|
}
|