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

+2
View File
@@ -94,6 +94,8 @@ MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS=https://my.ep-reisen.de/api/userinfo
MYEP_OAUTH2_SCOPES=email,id,roles,profile MYEP_OAUTH2_SCOPES=email,id,roles,profile
# Shared key the sibling websites send in the X-Api-Key header. Empty here: set it per stage. # Shared key the sibling websites send in the X-Api-Key header. Empty here: set it per stage.
APPLICATION_FORM_API_KEY= APPLICATION_FORM_API_KEY=
# Shared key n8n reads the placements feed with, in the X-Api-Key header. Empty here: set it per stage.
N8N_API_KEY=
# Booking link for the Team-Infocall, substituted for {bookingUrl} in the application form # Booking link for the Team-Infocall, substituted for {bookingUrl} in the application form
# receipt mail. Empty here: set it per stage. While it is empty that mail is not sent at all - # receipt mail. Empty here: set it per stage. While it is empty that mail is not sent at all -
# an invitation with a dead booking link is worse than none. # an invitation with a dead booking link is worse than none.
+2
View File
@@ -10,5 +10,7 @@ APP_TRUSTED_HOSTS='^localhost$'
# loaded in the test environment - so the throwaway key the tests send belongs here. # loaded in the test environment - so the throwaway key the tests send belongs here.
# Keep it in sync with ApplicationFormControllerTest::KEY. # Keep it in sync with ApplicationFormControllerTest::KEY.
APPLICATION_FORM_API_KEY=test-application-form-api-key APPLICATION_FORM_API_KEY=test-application-form-api-key
# Keep in sync with Api\Assignment\ListControllerTest::KEY.
N8N_API_KEY=test-n8n-api-key
# The receipt mail refuses to render without it, and several tests send that mail. # The receipt mail refuses to render without it, and several tests send that mail.
APPLICATION_FORM_BOOKING_URL=https://bookings.example.com/team-infocall APPLICATION_FORM_BOOKING_URL=https://bookings.example.com/team-infocall
+1
View File
@@ -13,6 +13,7 @@ security:
memory: memory:
users: users:
api: { roles: [ ROLE_API ] } api: { roles: [ ROLE_API ] }
n8n: { roles: [ ROLE_API_PLACEMENTS ] }
role_hierarchy: role_hierarchy:
ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ] ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ]
+44 -1
View File
@@ -40,6 +40,37 @@ parameters:
# rejects every request instead of letting them through. # rejects every request instead of letting them through.
application_form_api_key: '%env(string:APPLICATION_FORM_API_KEY)%' application_form_api_key: '%env(string:APPLICATION_FORM_API_KEY)%'
# Shared key n8n reads the placements feed with (GET /api/einsaetze). Same rules as above.
n8n_api_key: '%env(string:N8N_API_KEY)%'
# What the n8n placements feed covers: assignments in these houses (matched against the first
# three characters of destination.hotelCode) for these job profiles - guide, course and
# service-team work. Job profile ids are database ids, so check them against production.
placement_export.house_codes: [ DPW, DGS, DWW, DKI, DKS ]
placement_export.job_profile_ids:
- 5 # Reiseleitung
- 9 # Reiseleitung Ski
- 10 # Reiseleitung Snowboard
- 3 # Kursleitung Ski Anfänger:innen
- 14 # Kursleitung Ski Fortgeschrittene
- 18 # Kursleitung Ski Anfänger:innen + Fortgeschrittene
- 36 # Kursleitung Ski Kinder Anfänger:innen & Kinderbetreuung
- 37 # Kursleitung Ski Kinder Fortgeschrittene
- 38 # Kursleitung Ski Kinder
- 4 # Kursleitung Snowboard Anfänger:innen
- 15 # Kursleitung Snowboard Fortgeschrittene
- 19 # Kursleitung Snowboard Anfänger:innen + Fortgeschrittene
- 47 # Kursleitung Snowboard Kinder/Jugendliche Anfänger:innen
- 20 # Reise- und Kursleitung Ski Anfänger:innen
- 21 # Reise- und Kursleitung Ski Fortgeschrittene
- 22 # Reise- und Kursleitung Ski Anfänger:innen + Fortgeschrittene
- 23 # Reise- und Kursleitung Snowboard Anfänger:innen
- 24 # Reise- und Kursleitung Snowboard Fortgeschrittene
- 25 # Reise- und Kursleitung Snowboard Anfänger:innen + Fortgeschrittene
- 6 # Serviceteam 40h (ST40)
- 13 # Serviceteam 25h (ST25)
- 16 # Serviceteam 10h (ST10)
# Booking link the application form receipt mail invites the applicant to. A deployment # Booking link the application form receipt mail invites the applicant to. A deployment
# value rather than part of the wording, so a new booking system does not need an edit in # value rather than part of the wording, so a new booking system does not need an edit in
# every stage's mail text. # every stage's mail text.
@@ -437,7 +468,10 @@ services:
App\Security\AccessToken\ApiKeyTokenHandler: App\Security\AccessToken\ApiKeyTokenHandler:
arguments: arguments:
$apiKey: '%application_form_api_key%' # user identifier in security.yaml's api_user_provider => key
$apiKeys:
api: '%application_form_api_key%'
n8n: '%n8n_api_key%'
$logger: '@monolog.logger.api' $logger: '@monolog.logger.api'
App\Controller\Api\ApplicationFormController: App\Controller\Api\ApplicationFormController:
@@ -445,6 +479,13 @@ services:
$logger: '@monolog.logger.api' $logger: '@monolog.logger.api'
tags: [ 'controller.service_arguments' ] tags: [ 'controller.service_arguments' ]
App\Controller\Api\Assignment\ListController:
arguments:
$houseCodes: '%placement_export.house_codes%'
$jobProfileIds: '%placement_export.job_profile_ids%'
$logger: '@monolog.logger.api'
tags: [ 'controller.service_arguments' ]
App\EventListener\EmailRecipientWhitelistSubscriber: App\EventListener\EmailRecipientWhitelistSubscriber:
arguments: arguments:
$allowedRecipients: '%mail_recipient_whitelist%' $allowedRecipients: '%mail_recipient_whitelist%'
@@ -472,6 +513,8 @@ when@dev:
when@test: when@test:
parameters: parameters:
# a job profile id ListControllerTest inserts itself - db_test has no job profiles
placement_export.job_profile_ids: [ 900001 ]
mailing_bus_transport: 'sync' mailing_bus_transport: 'sync'
# the questionnaire tests need it offered; any day before the placements they create # the questionnaire tests need it offered; any day before the placements they create
questionnaire_go_live: '2026-01-01' questionnaire_go_live: '2026-01-01'
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Numbers the places of an assignment, for the n8n placements feed ("platz_id").
*
* Existing active placements are numbered 1..n per assignment in creation order; called-off ones
* stay without a number, they hold no place. n8n has not seen a place number yet, so the initial
* order is free to choose.
*
* Written by hand rather than taken from doctrine:migrations:diff, which also carries the
* pre-existing drift of the dev database.
*/
final class Version20261106100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add disposition.slot and number the active placements per assignment';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE disposition ADD slot SMALLINT DEFAULT NULL');
$this->addSql(<<<'SQL'
UPDATE disposition
INNER JOIN (
SELECT id, ROW_NUMBER() OVER (PARTITION BY assignment_id ORDER BY id) AS slot
FROM disposition
WHERE status <> 'called_off' AND assignment_id IS NOT NULL
) AS numbered ON numbered.id = disposition.id
SET disposition.slot = numbered.slot
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE disposition DROP slot');
}
}
@@ -0,0 +1,129 @@
<?php
namespace App\Controller\Api\Assignment;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\Upload;
use App\Enum\PlacementStatus;
use App\Model\PlacementExportQueryDto;
use App\Repository\AssignmentRepository;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The placements feed n8n reads: one row per place of the houses' guide, course and service-team
* assignments overlapping a date range. A row is a place, not a person - re-staffing a place
* changes the person on the same platz_id, and an open place has person_id null. A called-off
* assignment is left out entirely, as is a place the office struck.
*
* The path, the query parameters and the field names are the consumer's (German); errors are
* English, like every response of the api firewall.
*/
class ListController extends AbstractController
{
/**
* @param list<string> $houseCodes
* @param list<int> $jobProfileIds
*/
public function __construct(
private readonly AssignmentRepository $assignmentRepository,
private readonly array $houseCodes,
private readonly array $jobProfileIds,
private readonly LoggerInterface $logger,
) {
}
#[Route('/api/einsaetze', name: 'app_api_assignment_list', methods: ['GET'])]
#[IsGranted('ROLE_API_PLACEMENTS')]
public function index(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
// Deliberately without a default: the resolver would hand that over unvalidated for a
// request without any query string, instead of the 400 naming von and bis.
PlacementExportQueryDto $query,
): JsonResponse {
$rows = [];
$assignments = $this->assignmentRepository->findForPlacementExport(
$query->getFrom(),
$query->getTo(),
$this->houseCodes,
$this->jobProfileIds,
);
foreach ($assignments as $assignment) {
foreach ($assignment->getPlaces() as $slot => $disposition) {
$rows[] = $this->row($assignment, $slot, $disposition);
}
}
// the range and the count, never the rows - they name people
$this->logger->info('Placements exported', [
'from' => $query->von,
'to' => $query->bis,
'assignments' => count($assignments),
'places' => count($rows),
]);
return new JsonResponse($rows);
}
/**
* @return array<string, mixed>
*/
private function row(Assignment $assignment, int $slot, ?Disposition $disposition): array
{
$destination = $assignment->getDestination();
$jobProfile = $assignment->getJobProfile();
$teamer = $disposition?->getTeamer();
$code = (string) $destination->getCode();
return [
'platz_id' => sprintf('%d-%d', $assignment->getId(), $slot),
'einsatz_id' => $assignment->getId(),
'person_id' => $teamer?->getId(),
'vorname' => $teamer?->getFirstName(),
'nachname' => $teamer?->getLastName(),
'haus' => substr((string) $destination->getHotelCode(), 0, 3),
'von' => $destination->getDateFrom()->format('Y-m-d'),
'bis' => $destination->getDateTo()->format('Y-m-d'),
'jobprofil_id' => $jobProfile->getId(),
'jobprofil' => $jobProfile->getName(),
'status' => PlacementStatus::fromDisposition($disposition)->value,
'unter_26' => $this->isUnder26($disposition, $assignment),
'vertrag_liegt_vor' => $this->hasCheckedContract($disposition),
// Older codes carry a slash ("DPWBSP/030229"), current ones do not.
'reise_code' => '' === $code ? null : str_replace('/', '', $code),
];
}
/**
* Under 26 on the first day of the assignment.
*/
private function isUnder26(?Disposition $disposition, Assignment $assignment): ?bool
{
if (null === $dateOfBirth = $disposition?->getTeamer()?->getDateOfBirth()) {
return null;
}
return $dateOfBirth->modify('+26 years') > $assignment->getDestination()->getDateFrom();
}
/**
* Only once the office has checked it - an uploaded contract alone does not count. Null where
* there is no contract to have: an open place, or an assignment that skips the formalities.
*/
private function hasCheckedContract(?Disposition $disposition): ?bool
{
if (null === $disposition || true === $disposition->isSkipFormalities()) {
return null;
}
return true === $disposition->getDocumentByType(Upload::TYPE_CONTRACT)?->isConfirmed();
}
}
+72
View File
@@ -481,6 +481,78 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
}); });
} }
/**
* The lowest place number no active placement holds - the one a new placement takes.
*/
public function getNextFreeSlot(): int
{
return $this->nextFreeSlotIn(array_flip($this->getHeldSlots()));
}
/**
* The places the n8n feed reports, as place number => the active placement filling it, or
* null for an open place. A place is a row there, not a person, so a called-off teamer's place
* is open again under the same number rather than gone.
*
* Every held place is listed, topped up with the lowest free numbers to availableDispositions.
* Lowering the count while a high number is still held therefore drops an open low number, not
* the held one; a held place beyond the count is still listed, it has a person on it.
*
* @return array<int, Disposition|null>
*/
public function getPlaces(): array
{
$places = [];
$unnumbered = [];
foreach ($this->getActiveDispositions() as $disposition) {
if (null === $slot = $disposition->getSlot()) {
$unnumbered[] = $disposition;
} else {
$places[$slot] = $disposition;
}
}
// Without a number only when created outside DisposeController; such a placement takes
// the next free number after the numbered ones, so that nobody drops out of the feed.
foreach ($unnumbered as $disposition) {
$places[$this->nextFreeSlotIn($places)] = $disposition;
}
while (count($places) < (int) $this->availableDispositions) {
$places[$this->nextFreeSlotIn($places)] = null;
}
ksort($places);
return $places;
}
/**
* @return list<int>
*/
private function getHeldSlots(): array
{
return array_values(array_filter(
$this->getActiveDispositions()->map(fn (Disposition $disposition) => $disposition->getSlot())->toArray(),
fn (?int $slot) => null !== $slot,
));
}
/**
* @param array<int, mixed> $places
*/
private function nextFreeSlotIn(array $places): int
{
$slot = 1;
while (array_key_exists($slot, $places)) {
++$slot;
}
return $slot;
}
public function getConfirmedDispositions(): Collection public function getConfirmedDispositions(): Collection
{ {
return $this->dispositions->filter(function (Disposition $disposition) { return $this->dispositions->filter(function (Disposition $disposition) {
+23
View File
@@ -80,6 +80,16 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])] #[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
private bool $feedbackWaived = false; private bool $feedbackWaived = false;
/**
* The numbered place on the assignment this placement fills - the n8n feed's "platz_id" is
* "<assignment id>-<slot>", and n8n hangs the Wechselpass off it. Taken once, here at creation,
* and never renumbered: a replacement takes the number the called-off teamer freed, whoever
* stays keeps theirs. A called-off placement keeps its number for the record but no longer
* holds it (Assignment::getPlaces()).
*/
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $slot = null;
public function __construct(Application $application) public function __construct(Application $application)
{ {
$this->uuid = Uuid::v4(); $this->uuid = Uuid::v4();
@@ -89,6 +99,7 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
? static::STATUS_CONFIRMED ? static::STATUS_CONFIRMED
: static::STATUS_NEW; : static::STATUS_NEW;
$this->assignment = $application->getAssignment(); $this->assignment = $application->getAssignment();
$this->slot = $this->assignment?->getNextFreeSlot();
$this->teamer = $application->getTeamer(); $this->teamer = $application->getTeamer();
$this->pickup = $application->getPickup(); $this->pickup = $application->getPickup();
$this->specialAgreements = $application->getRequests(); $this->specialAgreements = $application->getRequests();
@@ -373,4 +384,16 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
return $this; return $this;
} }
public function getSlot(): ?int
{
return $this->slot;
}
public function setSlot(?int $slot): static
{
$this->slot = $slot;
return $this;
}
} }
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Enum;
use App\Entity\Disposition;
/**
* The status of a place in the n8n placements feed. The values are the feed's contract.
*/
enum PlacementStatus: string
{
case OPEN = 'offen';
case REQUESTED = 'angefragt';
case CONTRACT = 'vertrag';
case FIXED = 'fix';
/**
* A called-off placement holds no place, so it never reaches this - its place is open.
*/
public static function fromDisposition(?Disposition $disposition): self
{
return match ($disposition?->getStatus()) {
null, Disposition::STATUS_CALLED_OFF => self::OPEN,
Disposition::STATUS_NEW => self::REQUESTED,
Disposition::STATUS_CHECKING_CONTRACT => self::CONTRACT,
default => self::FIXED,
};
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
/**
* The date range of GET /api/einsaetze, mapped from the query string. The parameter names are
* the consumer's (n8n).
*/
class PlacementExportQueryDto
{
public function __construct(
#[Assert\NotBlank(message: 'von must not be empty.')]
#[Assert\Date(message: 'von must be a date as YYYY-MM-DD.')]
public readonly ?string $von = null,
#[Assert\NotBlank(message: 'bis must not be empty.')]
#[Assert\Date(message: 'bis must be a date as YYYY-MM-DD.')]
#[Assert\GreaterThanOrEqual(propertyPath: 'von', message: 'bis must not be before von.')]
public readonly ?string $bis = null,
) {
}
public function getFrom(): \DateTimeImmutable
{
return new \DateTimeImmutable($this->von);
}
public function getTo(): \DateTimeImmutable
{
return new \DateTimeImmutable($this->bis);
}
}
+49
View File
@@ -431,4 +431,53 @@ class AssignmentRepository extends ServiceEntityRepository
->getSingleColumnResult() ->getSingleColumnResult()
); );
} }
/**
* The assignments the n8n placements feed reports: published, in one of the houses, for one of
* the job profiles, and overlapping the range. Placements, their teamers and documents come
* fetch-joined - every place row needs them. All placements are loaded, called-off ones included:
* a fetch join on a filtered collection would leave the assignment with a partial one, and
* Assignment::getPlaces() leaves them out anyway.
*
* @param list<string> $houseCodes matched against the first three characters of the hotel code
* @param list<int> $jobProfileIds
*
* @return list<Assignment>
*/
public function findForPlacementExport(\DateTimeImmutable $from, \DateTimeImmutable $to, array $houseCodes, array $jobProfileIds): array
{
if ([] === $houseCodes || [] === $jobProfileIds) {
return [];
}
$qb = $this->createQueryBuilder('assignment');
return $qb
->addSelect('destination', 'jobProfile', 'disposition', 'teamer', 'document')
->innerJoin('assignment.destination', 'destination')
->innerJoin('assignment.jobProfile', 'jobProfile')
->leftJoin('assignment.dispositions', 'disposition')
->leftJoin('disposition.teamer', 'teamer')
->leftJoin('disposition.documents', 'document')
->where($qb->expr()->andX(
$qb->expr()->lte('destination.dateFrom', ':to'),
$qb->expr()->gte('destination.dateTo', ':from'),
$qb->expr()->in('SUBSTRING(destination.hotelCode, 1, 3)', ':houseCodes'),
$qb->expr()->in('jobProfile.id', ':jobProfileIds'),
$qb->expr()->notIn('assignment.status', ':excludedStatuses'),
$qb->expr()->isNull('assignment.deletedAt'),
$qb->expr()->isNull('destination.deletedAt'),
))
->orderBy('destination.dateFrom')
->addOrderBy('destination.hotelCode')
->addOrderBy('assignment.id')
->setParameter('from', $from->format('Y-m-d'))
->setParameter('to', $to->format('Y-m-d'))
->setParameter('houseCodes', $houseCodes)
->setParameter('jobProfileIds', $jobProfileIds)
->setParameter('excludedStatuses', [Assignment::STATUS_DRAFT, Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
->getQuery()
->getResult()
;
}
} }
+24 -16
View File
@@ -8,38 +8,46 @@ use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
/** /**
* Validates the shared key a sibling website posts with. There is no person to log in and no * Validates the shared key a machine caller sends. There is no person to log in and no account to
* account to look up: the key is the identity, and the user it resolves to is the in-memory * look up: the key is the identity, and the user it resolves to is one of the in-memory users of
* "api" user of the api_user_provider - nothing about it may reach the user table, where a row * the api_user_provider - nothing about it may reach the user table, where a row would be a login
* would be a login to this application's UI. * to this application's UI.
*
* One key per caller, each resolving to its own user and roles: the websites posting application
* forms must not read placements, and n8n reading placements must not post forms.
*/ */
class ApiKeyTokenHandler implements AccessTokenHandlerInterface class ApiKeyTokenHandler implements AccessTokenHandlerInterface
{ {
public const USER_IDENTIFIER = 'api'; /**
* @param array<string, string> $apiKeys user identifier => key
*/
public function __construct( public function __construct(
private readonly string $apiKey, private readonly array $apiKeys,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
} }
public function getUserBadgeFrom(string $accessToken): UserBadge public function getUserBadgeFrom(string $accessToken): UserBadge
{ {
// An unconfigured key locks the endpoint instead of opening it: hash_equals('', '') // An unconfigured key locks its caller out instead of opening the door: hash_equals('', '')
// is true, so without this check a deployment that forgot the secret would accept // is true, so without this filter a deployment that forgot a secret would accept an empty
// an empty key from anyone. // key from anyone.
if ('' === $this->apiKey) { $configuredKeys = array_filter($this->apiKeys, fn (string $key) => '' !== $key);
if ([] === $configuredKeys) {
$this->logger->error('API key authentication attempted while no key is configured'); $this->logger->error('API key authentication attempted while no key is configured');
throw new BadCredentialsException('No API key configured.'); throw new BadCredentialsException('No API key configured.');
} }
if (false === hash_equals($this->apiKey, $accessToken)) { foreach ($configuredKeys as $identifier => $key) {
$this->logger->warning('API key authentication failed'); if (true === hash_equals($key, $accessToken)) {
return new UserBadge((string) $identifier);
throw new BadCredentialsException('Invalid API key.'); }
} }
return new UserBadge(self::USER_IDENTIFIER); $this->logger->warning('API key authentication failed');
throw new BadCredentialsException('Invalid API key.');
} }
} }
+3 -1
View File
@@ -48,8 +48,10 @@ class ApplicationVoter extends Voter
return match ($attribute) { return match ($attribute) {
static::STATUS, static::DELETE => true, static::STATUS, static::DELETE => true,
static::VIEW => Application::STATUS_REJECTED !== $status, static::VIEW => Application::STATUS_REJECTED !== $status,
// A called-off placement frees its place, so it does not count here: re-staffing
// must not take raising availableDispositions, which would add a place.
static::DISPOSE => Application::STATUS_REJECTED !== $status static::DISPOSE => Application::STATUS_REJECTED !== $status
&& $assignment->getAvailableDispositions() > $assignment->getDispositions()->count(), && $assignment->getAvailableDispositions() > $assignment->getActiveDispositions()->count(),
default => false, default => false,
}; };
} }
@@ -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]; 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 private function createDisposition(Assignment $assignment, Teamer $teamer): Disposition
{ {
$disposition = new Disposition(new Application($assignment, $teamer)); $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],
);
}
}