feat: expose winter placements to n8n over an api endpoint
This commit is contained in:
1 parent
4ff963d1e2
commit
0bc77b7463
17 files changed
+977
-18
No files matched your search
@@ -0,0 +1,314 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user