diff --git a/.env b/.env index ae623cb..18b77bc 100644 --- a/.env +++ b/.env @@ -94,6 +94,8 @@ MYEP_OAUTH2_URL_RESOURCE_OWNER_DETAILS=https://my.ep-reisen.de/api/userinfo 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. 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 # 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. diff --git a/.env.test b/.env.test index 9467ea6..a230031 100644 --- a/.env.test +++ b/.env.test @@ -10,5 +10,7 @@ APP_TRUSTED_HOSTS='^localhost$' # loaded in the test environment - so the throwaway key the tests send belongs here. # Keep it in sync with ApplicationFormControllerTest::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. APPLICATION_FORM_BOOKING_URL=https://bookings.example.com/team-infocall diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 48f07db..ec8b79f 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -13,6 +13,7 @@ security: memory: users: api: { roles: [ ROLE_API ] } + n8n: { roles: [ ROLE_API_PLACEMENTS ] } role_hierarchy: ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ] diff --git a/config/services.yaml b/config/services.yaml index 9a7eb22..67769ec 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -40,6 +40,37 @@ parameters: # rejects every request instead of letting them through. 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 # value rather than part of the wording, so a new booking system does not need an edit in # every stage's mail text. @@ -437,7 +468,10 @@ services: App\Security\AccessToken\ApiKeyTokenHandler: 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' App\Controller\Api\ApplicationFormController: @@ -445,6 +479,13 @@ services: $logger: '@monolog.logger.api' 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: arguments: $allowedRecipients: '%mail_recipient_whitelist%' @@ -472,6 +513,8 @@ when@dev: when@test: parameters: + # a job profile id ListControllerTest inserts itself - db_test has no job profiles + placement_export.job_profile_ids: [ 900001 ] mailing_bus_transport: 'sync' # the questionnaire tests need it offered; any day before the placements they create questionnaire_go_live: '2026-01-01' diff --git a/migrations/Version20261106100000.php b/migrations/Version20261106100000.php new file mode 100644 index 0000000..7ee3a40 --- /dev/null +++ b/migrations/Version20261106100000.php @@ -0,0 +1,45 @@ +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'); + } +} diff --git a/src/Controller/Api/Assignment/ListController.php b/src/Controller/Api/Assignment/ListController.php new file mode 100644 index 0000000..e51ed59 --- /dev/null +++ b/src/Controller/Api/Assignment/ListController.php @@ -0,0 +1,129 @@ + $houseCodes + * @param list $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 + */ + 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(); + } +} diff --git a/src/Entity/Assignment.php b/src/Entity/Assignment.php index 3872193..df555e8 100644 --- a/src/Entity/Assignment.php +++ b/src/Entity/Assignment.php @@ -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 + */ + 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 + */ + 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 $places + */ + private function nextFreeSlotIn(array $places): int + { + $slot = 1; + + while (array_key_exists($slot, $places)) { + ++$slot; + } + + return $slot; + } + public function getConfirmedDispositions(): Collection { return $this->dispositions->filter(function (Disposition $disposition) { diff --git a/src/Entity/Disposition.php b/src/Entity/Disposition.php index 7b6aceb..d97b8c4 100644 --- a/src/Entity/Disposition.php +++ b/src/Entity/Disposition.php @@ -80,6 +80,16 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf #[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])] private bool $feedbackWaived = false; + /** + * The numbered place on the assignment this placement fills - the n8n feed's "platz_id" is + * "-", 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) { $this->uuid = Uuid::v4(); @@ -89,6 +99,7 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf ? static::STATUS_CONFIRMED : static::STATUS_NEW; $this->assignment = $application->getAssignment(); + $this->slot = $this->assignment?->getNextFreeSlot(); $this->teamer = $application->getTeamer(); $this->pickup = $application->getPickup(); $this->specialAgreements = $application->getRequests(); @@ -373,4 +384,16 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf return $this; } + + public function getSlot(): ?int + { + return $this->slot; + } + + public function setSlot(?int $slot): static + { + $this->slot = $slot; + + return $this; + } } diff --git a/src/Enum/PlacementStatus.php b/src/Enum/PlacementStatus.php new file mode 100644 index 0000000..4f0f57f --- /dev/null +++ b/src/Enum/PlacementStatus.php @@ -0,0 +1,29 @@ +getStatus()) { + null, Disposition::STATUS_CALLED_OFF => self::OPEN, + Disposition::STATUS_NEW => self::REQUESTED, + Disposition::STATUS_CHECKING_CONTRACT => self::CONTRACT, + default => self::FIXED, + }; + } +} diff --git a/src/Model/PlacementExportQueryDto.php b/src/Model/PlacementExportQueryDto.php new file mode 100644 index 0000000..3d63082 --- /dev/null +++ b/src/Model/PlacementExportQueryDto.php @@ -0,0 +1,33 @@ +von); + } + + public function getTo(): \DateTimeImmutable + { + return new \DateTimeImmutable($this->bis); + } +} diff --git a/src/Repository/AssignmentRepository.php b/src/Repository/AssignmentRepository.php index 337f90d..73ac4af 100644 --- a/src/Repository/AssignmentRepository.php +++ b/src/Repository/AssignmentRepository.php @@ -431,4 +431,53 @@ class AssignmentRepository extends ServiceEntityRepository ->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 $houseCodes matched against the first three characters of the hotel code + * @param list $jobProfileIds + * + * @return list + */ + 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() + ; + } } diff --git a/src/Security/AccessToken/ApiKeyTokenHandler.php b/src/Security/AccessToken/ApiKeyTokenHandler.php index dd3db65..f0e1292 100644 --- a/src/Security/AccessToken/ApiKeyTokenHandler.php +++ b/src/Security/AccessToken/ApiKeyTokenHandler.php @@ -8,38 +8,46 @@ use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; 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 - * account to look up: the key is the identity, and the user it resolves to is the in-memory - * "api" user of the api_user_provider - nothing about it may reach the user table, where a row - * would be a login to this application's UI. + * Validates the shared key a machine caller sends. There is no person to log in and no account to + * look up: the key is the identity, and the user it resolves to is one of the in-memory users of + * the api_user_provider - nothing about it may reach the user table, where a row would be a login + * 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 { - public const USER_IDENTIFIER = 'api'; - + /** + * @param array $apiKeys user identifier => key + */ public function __construct( - private readonly string $apiKey, + private readonly array $apiKeys, private readonly LoggerInterface $logger, ) { } public function getUserBadgeFrom(string $accessToken): UserBadge { - // An unconfigured key locks the endpoint instead of opening it: hash_equals('', '') - // is true, so without this check a deployment that forgot the secret would accept - // an empty key from anyone. - if ('' === $this->apiKey) { + // An unconfigured key locks its caller out instead of opening the door: hash_equals('', '') + // is true, so without this filter a deployment that forgot a secret would accept an empty + // key from anyone. + $configuredKeys = array_filter($this->apiKeys, fn (string $key) => '' !== $key); + + if ([] === $configuredKeys) { $this->logger->error('API key authentication attempted while no key is configured'); throw new BadCredentialsException('No API key configured.'); } - if (false === hash_equals($this->apiKey, $accessToken)) { - $this->logger->warning('API key authentication failed'); - - throw new BadCredentialsException('Invalid API key.'); + foreach ($configuredKeys as $identifier => $key) { + if (true === hash_equals($key, $accessToken)) { + return new UserBadge((string) $identifier); + } } - return new UserBadge(self::USER_IDENTIFIER); + $this->logger->warning('API key authentication failed'); + + throw new BadCredentialsException('Invalid API key.'); } } diff --git a/src/Security/Voter/ApplicationVoter.php b/src/Security/Voter/ApplicationVoter.php index f44520f..609548c 100644 --- a/src/Security/Voter/ApplicationVoter.php +++ b/src/Security/Voter/ApplicationVoter.php @@ -48,8 +48,10 @@ class ApplicationVoter extends Voter return match ($attribute) { static::STATUS, static::DELETE => true, 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 - && $assignment->getAvailableDispositions() > $assignment->getDispositions()->count(), + && $assignment->getAvailableDispositions() > $assignment->getActiveDispositions()->count(), default => false, }; } diff --git a/tests/Controller/Api/Assignment/ListControllerTest.php b/tests/Controller/Api/Assignment/ListControllerTest.php new file mode 100644 index 0000000..cec33b1 --- /dev/null +++ b/tests/Controller/Api/Assignment/ListControllerTest.php @@ -0,0 +1,314 @@ +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 + */ + private function decode(): array + { + return json_decode((string) $this->client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Entity/AssignmentTest.php b/tests/Entity/AssignmentTest.php index d021887..48a8e25 100644 --- a/tests/Entity/AssignmentTest.php +++ b/tests/Entity/AssignmentTest.php @@ -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)); diff --git a/tests/Security/AccessToken/ApiKeyTokenHandlerTest.php b/tests/Security/AccessToken/ApiKeyTokenHandlerTest.php new file mode 100644 index 0000000..f103df4 --- /dev/null +++ b/tests/Security/AccessToken/ApiKeyTokenHandlerTest.php @@ -0,0 +1,70 @@ +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 $keys + */ + private function handler(array $keys): ApiKeyTokenHandler + { + return new ApiKeyTokenHandler($keys, new NullLogger()); + } +} diff --git a/tests/Security/Voter/ApplicationVoterTest.php b/tests/Security/Voter/ApplicationVoterTest.php new file mode 100644 index 0000000..b4622e6 --- /dev/null +++ b/tests/Security/Voter/ApplicationVoterTest.php @@ -0,0 +1,61 @@ +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], + ); + } +}