feat: expose winter placements to n8n over an api endpoint

This commit is contained in:
fromme committed 2026-11-06 10:09:56 +01:00
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);
}
}
+76
View File
@@ -83,6 +83,82 @@ class AssignmentTest extends TestCase
yield 'pickup' => [4711, true];
}
public function testTheFirstPlacementTakesPlaceOne(): void
{
$this->assertSame(1, $this->createDisposition(new Assignment(), new Teamer())->getSlot());
}
/**
* The n8n feed hangs the Wechselpass off the place number: a replacement takes the number the
* called-off teamer freed, and whoever stays keeps theirs.
*/
public function testAReplacementTakesTheFreedPlaceAndTheStayerKeepsTheirs(): void
{
$assignment = (new Assignment())->setAvailableDispositions(2);
$first = $this->createDisposition($assignment, new Teamer());
$second = $this->createDisposition($assignment, new Teamer());
$first->setStatus(Disposition::STATUS_CALLED_OFF);
$replacement = $this->createDisposition($assignment, new Teamer());
$this->assertSame(1, $replacement->getSlot());
$this->assertSame(2, $second->getSlot());
$this->assertSame([1 => $replacement, 2 => $second], $assignment->getPlaces());
}
public function testACalledOffPlacementLeavesAnOpenPlace(): void
{
$assignment = (new Assignment())->setAvailableDispositions(2);
$calledOff = $this->createDisposition($assignment, new Teamer());
$stayer = $this->createDisposition($assignment, new Teamer());
$calledOff->setStatus(Disposition::STATUS_CALLED_OFF);
$this->assertSame([1 => null, 2 => $stayer], $assignment->getPlaces());
}
public function testUnfilledPlacesAreOpen(): void
{
$assignment = (new Assignment())->setAvailableDispositions(3);
$disposition = $this->createDisposition($assignment, new Teamer());
$this->assertSame([1 => $disposition, 2 => null, 3 => null], $assignment->getPlaces());
}
/**
* Striking a place while a high number is still held drops an open low one, not the person.
*/
public function testLoweringTheCountKeepsTheHeldPlace(): void
{
$assignment = (new Assignment())->setAvailableDispositions(2);
$calledOff = $this->createDisposition($assignment, new Teamer());
$stayer = $this->createDisposition($assignment, new Teamer());
$calledOff->setStatus(Disposition::STATUS_CALLED_OFF);
$assignment->setAvailableDispositions(1);
$this->assertSame([2 => $stayer], $assignment->getPlaces());
}
public function testAPlaceHeldBeyondTheCountIsStillListed(): void
{
$assignment = (new Assignment())->setAvailableDispositions(2);
$first = $this->createDisposition($assignment, new Teamer());
$second = $this->createDisposition($assignment, new Teamer());
$assignment->setAvailableDispositions(1);
$this->assertSame([1 => $first, 2 => $second], $assignment->getPlaces());
}
public function testAnUnnumberedPlacementTakesAFreeNumberAfterTheNumberedOnes(): void
{
$assignment = (new Assignment())->setAvailableDispositions(2);
$unnumbered = $this->createDisposition($assignment, new Teamer())->setSlot(null);
$numbered = $this->createDisposition($assignment, new Teamer())->setSlot(1);
$this->assertSame([1 => $numbered, 2 => $unnumbered], $assignment->getPlaces());
}
private function createDisposition(Assignment $assignment, Teamer $teamer): Disposition
{
$disposition = new Disposition(new Application($assignment, $teamer));
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security\AccessToken;
use App\Security\AccessToken\ApiKeyTokenHandler;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
/**
* Each key resolves to its own caller, and an unconfigured key must not open the door for an
* empty one: hash_equals('', '') is true.
*/
class ApiKeyTokenHandlerTest extends TestCase
{
public function testEachKeyResolvesToItsOwnCaller(): void
{
$handler = $this->handler(['api' => 'forms-key', 'n8n' => 'n8n-key']);
$this->assertSame('api', $handler->getUserBadgeFrom('forms-key')->getUserIdentifier());
$this->assertSame('n8n', $handler->getUserBadgeFrom('n8n-key')->getUserIdentifier());
}
public function testAWrongKeyIsRejected(): void
{
$this->expectException(BadCredentialsException::class);
$this->handler(['api' => 'forms-key', 'n8n' => 'n8n-key'])->getUserBadgeFrom('other-key');
}
/**
* The extractor never hands over an empty key, but the handler must not rely on that.
*/
public function testAnUnconfiguredKeyDoesNotAcceptAnEmptyOne(): void
{
$this->expectException(BadCredentialsException::class);
$this->handler(['api' => 'forms-key', 'n8n' => ''])->getUserBadgeFrom('');
}
public function testAnUnconfiguredKeyLeavesTheOtherWorking(): void
{
$this->assertSame('api', $this->handler(['api' => 'forms-key', 'n8n' => ''])->getUserBadgeFrom('forms-key')->getUserIdentifier());
}
#[DataProvider('anyKey')]
public function testNoConfiguredKeyRejectsEverything(string $key): void
{
$this->expectException(BadCredentialsException::class);
$this->handler(['api' => '', 'n8n' => ''])->getUserBadgeFrom($key);
}
public static function anyKey(): iterable
{
yield 'empty' => [''];
yield 'some' => ['forms-key'];
}
/**
* @param array<string, string> $keys
*/
private function handler(array $keys): ApiKeyTokenHandler
{
return new ApiKeyTokenHandler($keys, new NullLogger());
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security\Voter;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\Teamer;
use App\Security\Voter\ApplicationVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* A called-off placement frees its place. Counting it would leave the office with raising
* availableDispositions as the only way to re-staff - which adds a place to the n8n feed, where a
* place is a row the Wechselpass hangs off.
*/
class ApplicationVoterTest extends TestCase
{
public function testACalledOffPlaceCanBeStaffedAgain(): void
{
$assignment = (new Assignment())->setAvailableDispositions(2);
$this->dispose($assignment);
$this->dispose($assignment)->setStatus(Disposition::STATUS_CALLED_OFF);
$this->assertSame(VoterInterface::ACCESS_GRANTED, $this->voteDispose($assignment));
}
public function testAFullAssignmentCannotBeStaffed(): void
{
$assignment = (new Assignment())->setAvailableDispositions(2);
$this->dispose($assignment);
$this->dispose($assignment);
$this->assertSame(VoterInterface::ACCESS_DENIED, $this->voteDispose($assignment));
}
private function dispose(Assignment $assignment): Disposition
{
$disposition = new Disposition(new Application($assignment, new Teamer()));
$assignment->addDisposition($disposition);
return $disposition;
}
private function voteDispose(Assignment $assignment): int
{
$security = $this->createStub(Security::class);
$security->method('isGranted')->willReturnCallback(fn (string $role) => 'ROLE_ADMINISTRATIVE' === $role);
return (new ApplicationVoter($security))->vote(
$this->createStub(TokenInterface::class),
new Application($assignment, new Teamer()),
[ApplicationVoter::DISPOSE],
);
}
}