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); } }