diff --git a/src/EventListener/InvalidateApplicationsListener.php b/src/EventListener/InvalidateApplicationsListener.php index 47bb331..db12ca7 100644 --- a/src/EventListener/InvalidateApplicationsListener.php +++ b/src/EventListener/InvalidateApplicationsListener.php @@ -2,18 +2,22 @@ namespace App\EventListener; -use App\Entity\Application; use App\Entity\Upload; +use App\Event\ApplicationDeletedEvent; use App\Event\DocumentConfirmedEvent; +use App\Repository\ApplicationRepository; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; #[AsEventListener(event: DocumentConfirmedEvent::NAME, method: 'onDocumentConfirmed')] class InvalidateApplicationsListener { public function __construct( + private readonly ApplicationRepository $applicationRepository, private readonly EntityManagerInterface $entityManager, + private readonly EventDispatcherInterface $eventDispatcher, private readonly LoggerInterface $logger, ) { } @@ -33,73 +37,39 @@ class InvalidateApplicationsListener return; } - $assignment = $disposition->getAssignment(); - $period = $assignment->getEffectivePeriod(); + if (null === $period = $disposition->getAssignment()->getEffectivePeriod()) { + return; + } - // Find other applications by this teamer with overlapping date ranges - // Two periods overlap when: (start1 < end2) AND (end1 > start2) - // This catches all overlap scenarios while excluding boundary-only touching: - // - Partial overlaps from either direction - // - Complete containment in either direction - // - Exact date matches - // Note: Periods that only touch at boundaries (e.g., Jan 1-7 and Jan 7-12) are NOT considered overlapping - // The effective dates are determined by checking assignment dates first, falling back to destination dates if null - $qb = $this - ->entityManager - ->getRepository(Application::class) - ->createQueryBuilder('application'); - - /** @var array $applications */ - $applications = $qb - ->innerJoin('application.assignment', 'assignment') - ->innerJoin('assignment.destination', 'destination') - ->where($qb->expr()->andX( - $qb->expr()->eq('application.teamer', ':teamer'), - $qb->expr()->orX( - $qb->expr()->andX( - $qb->expr()->isNull('assignment.dateFrom'), - $qb->expr()->lt('destination.dateFrom', ':dateTo') - ), - $qb->expr()->andX( - $qb->expr()->isNotNull('assignment.dateFrom'), - $qb->expr()->lt('assignment.dateFrom', ':dateTo') - ) - ), - $qb->expr()->orX( - $qb->expr()->andX( - $qb->expr()->isNull('assignment.dateTo'), - $qb->expr()->gt('destination.dateTo', ':dateFrom') - ), - $qb->expr()->andX( - $qb->expr()->isNotNull('assignment.dateTo'), - $qb->expr()->gt('assignment.dateTo', ':dateFrom') - ) - ), - $qb->expr()->neq('application.status', ':status') - )) - ->setParameter('teamer', $teamer) - ->setParameter('dateFrom', $period->start->toDateTimeImmutable()) - ->setParameter('dateTo', $period->end->toDateTimeImmutable()) - ->setParameter('status', Application::STATUS_REJECTED) - ->getQuery() - ->getResult() - ; + $applications = $this->applicationRepository->findOverlappingForTeamer($teamer, $period); if (0 === count($applications)) { return; } foreach ($applications as $application) { - $destination = $application->getAssignment()->getDestination(); + $assignment = $application->getAssignment(); $this->logger->info('Deleted overlapping application', [ - 'teamer' => $teamer, - 'destination' => $destination->getHotelCode(), - 'date_from' => $destination->getDateFrom()->format('Y-m-d'), - 'date_to' => $destination->getDateTo()->format('Y-m-d'), + 'application' => $application->getUuid(), + 'assignment' => $assignment->getUuid(), + 'teamer' => $teamer->getUuid(), + 'teamer_name' => (string) $teamer, + 'destination' => $assignment->getDestination()->getHotelCode(), + 'date_from' => $assignment->getEffectiveDateFrom()->format('Y-m-d'), + 'date_to' => $assignment->getEffectiveDateTo()->format('Y-m-d'), ]); $this->entityManager->remove($application); } $this->entityManager->flush(); + + // the staffing status of the affected assignments and the audit log are both updated by + // listeners of this event, so it has to be dispatched after the applications are gone + foreach ($applications as $application) { + $this->eventDispatcher->dispatch( + new ApplicationDeletedEvent($application), + ApplicationDeletedEvent::NAME + ); + } } } diff --git a/src/Repository/ApplicationRepository.php b/src/Repository/ApplicationRepository.php index f857c1f..9a4e330 100644 --- a/src/Repository/ApplicationRepository.php +++ b/src/Repository/ApplicationRepository.php @@ -8,6 +8,7 @@ use App\Entity\Teamer; use App\Model\ApplicationFilterDto; use App\Model\AssignmentFilterDto; use App\Repository\Traits\QueryHelperTrait; +use Carbon\CarbonPeriod; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\Query; use Doctrine\Persistence\ManagerRegistry; @@ -161,6 +162,67 @@ class ApplicationRepository extends ServiceEntityRepository ; } + /** + * Finds all applications of a teamer whose effective assignment period overlaps the given period. + * + * The effective period of an assignment is its own date range, falling back to the date range of + * its destination whenever the assignment does not override it - the query mirrors + * {@see Assignment::getEffectivePeriod()}. Boundaries count as an overlap: a teamer cannot end one + * assignment on the same day another one begins. + * + * @return array + */ + public function findOverlappingForTeamer(Teamer $teamer, CarbonPeriod $period): array + { + return $this + ->getOverlappingForTeamerQuery($teamer, $period) + ->getResult() + ; + } + + public function getOverlappingForTeamerQuery(Teamer $teamer, CarbonPeriod $period): Query + { + $qb = $this->createQueryBuilder('application'); + + return $qb + ->innerJoin('application.assignment', 'assignment') + ->innerJoin('assignment.destination', 'destination') + ->where($qb->expr()->andX( + $qb->expr()->eq('application.teamer', ':teamer'), + $qb->expr()->neq('application.status', ':application_rejected'), + $qb->expr()->neq('assignment.status', ':assignment_called_off'), + $qb->expr()->isNull('assignment.deletedAt'), + $qb->expr()->isNull('destination.deletedAt'), + $qb->expr()->orX( + $qb->expr()->andX( + $qb->expr()->isNull('assignment.dateFrom'), + $qb->expr()->lte('destination.dateFrom', ':dateTo') + ), + $qb->expr()->andX( + $qb->expr()->isNotNull('assignment.dateFrom'), + $qb->expr()->lte('assignment.dateFrom', ':dateTo') + ) + ), + $qb->expr()->orX( + $qb->expr()->andX( + $qb->expr()->isNull('assignment.dateTo'), + $qb->expr()->gte('destination.dateTo', ':dateFrom') + ), + $qb->expr()->andX( + $qb->expr()->isNotNull('assignment.dateTo'), + $qb->expr()->gte('assignment.dateTo', ':dateFrom') + ) + ) + )) + ->setParameter('teamer', $teamer) + ->setParameter('application_rejected', Application::STATUS_REJECTED) + ->setParameter('assignment_called_off', Assignment::STATUS_CALLED_OFF) + ->setParameter('dateFrom', $period->start->toDateTimeImmutable()) + ->setParameter('dateTo', $period->end->toDateTimeImmutable()) + ->getQuery() + ; + } + public function getCurrentByAssignment(Assignment $assignment): array { $qb = $this->createQueryBuilder('application'); diff --git a/src/Validator/Constraints/ApplicationValidator.php b/src/Validator/Constraints/ApplicationValidator.php index e4a32d1..b91b720 100644 --- a/src/Validator/Constraints/ApplicationValidator.php +++ b/src/Validator/Constraints/ApplicationValidator.php @@ -37,17 +37,21 @@ class ApplicationValidator extends ConstraintValidator return; } - $assignment = $application->getAssignment(); - $destination = $assignment->getDestination(); + if (null === $period = $application->getAssignment()->getEffectivePeriod()) { + return; + } - $applicationDateFrom = $destination->getDateFrom(); - $applicationDateTo = $destination->getDateTo(); + $applicationDateFrom = $period->start->toDateTimeImmutable(); + $applicationDateTo = $period->end->toDateTimeImmutable(); $qb = $this ->dispositionRepository ->createQueryBuilder('disposition') ; + // the same overlap test as App\Repository\ApplicationRepository::findOverlappingForTeamer(), + // so an application that passes this validation is not purged later on by + // App\EventListener\InvalidateApplicationsListener $conflictingCount = $qb ->select($qb->expr()->count('disposition')) ->innerJoin('disposition.assignment', 'assignment') @@ -60,12 +64,22 @@ class ApplicationValidator extends ConstraintValidator $qb->expr()->isNull('destination.deletedAt'), $qb->expr()->orX( $qb->expr()->andX( - $qb->expr()->gt('destination.dateTo', ':application_date_from'), - $qb->expr()->lt('destination.dateTo', ':application_date_to'), + $qb->expr()->isNull('assignment.dateFrom'), + $qb->expr()->lte('destination.dateFrom', ':application_date_to') ), $qb->expr()->andX( - $qb->expr()->lt('destination.dateFrom', ':application_date_to'), - $qb->expr()->gt('destination.dateFrom', ':application_date_from'), + $qb->expr()->isNotNull('assignment.dateFrom'), + $qb->expr()->lte('assignment.dateFrom', ':application_date_to') + ) + ), + $qb->expr()->orX( + $qb->expr()->andX( + $qb->expr()->isNull('assignment.dateTo'), + $qb->expr()->gte('destination.dateTo', ':application_date_from') + ), + $qb->expr()->andX( + $qb->expr()->isNotNull('assignment.dateTo'), + $qb->expr()->gte('assignment.dateTo', ':application_date_from') ) ) )) diff --git a/tests/EventListener/InvalidateApplicationsListenerTest.php b/tests/EventListener/InvalidateApplicationsListenerTest.php index 55a354e..59af75f 100644 --- a/tests/EventListener/InvalidateApplicationsListenerTest.php +++ b/tests/EventListener/InvalidateApplicationsListenerTest.php @@ -10,326 +10,218 @@ use App\Entity\Destination; use App\Entity\Disposition; use App\Entity\Teamer; use App\Entity\Upload; +use App\Event\ApplicationDeletedEvent; use App\Event\DocumentConfirmedEvent; use App\EventListener\InvalidateApplicationsListener; use App\Repository\ApplicationRepository; use Carbon\CarbonPeriod; -use Doctrine\ORM\AbstractQuery; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Query\Expr; -use Doctrine\ORM\QueryBuilder; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use Symfony\Contracts\EventDispatcher\Event; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; class InvalidateApplicationsListenerTest extends TestCase { + private ApplicationRepository&MockObject $applicationRepository; private EntityManagerInterface&MockObject $entityManager; private InvalidateApplicationsListener $listener; + + /** @var array */ private array $removedApplications = []; + /** @var array */ + private array $dispatchedEvents = []; + protected function setUp(): void { + $this->applicationRepository = $this->createMock(ApplicationRepository::class); $this->entityManager = $this->createMock(EntityManagerInterface::class); - $logger = $this->createMock(LoggerInterface::class); + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); $this->removedApplications = []; + $this->dispatchedEvents = []; $this->entityManager ->method('remove') ->willReturnCallback(function (Application $application): void { $this->removedApplications[] = $application; - }); + }) + ; + + $eventDispatcher + ->method('dispatch') + ->willReturnCallback(function (Event $event): Event { + $this->dispatchedEvents[] = $event; + + return $event; + }) + ; $this->listener = new InvalidateApplicationsListener( + $this->applicationRepository, $this->entityManager, - $logger + $eventDispatcher, + $this->createMock(LoggerInterface::class) ); } - public function testDoesNotInvalidateApplicationsForNonContractDocuments(): void + public function testIgnoresNonContractDocuments(): void { - $document = $this->createMockUpload(Upload::TYPE_INVOICE); + $document = $this->createMock(Upload::class); + $document->method('getType')->willReturn(Upload::TYPE_INVOICE); - $this->entityManager - ->expects($this->never()) - ->method('getRepository'); + $this->applicationRepository->expects($this->never())->method('findOverlappingForTeamer'); + $this->entityManager->expects($this->never())->method('flush'); - $event = new DocumentConfirmedEvent($document); - $this->listener->onDocumentConfirmed($event); + $this->listener->onDocumentConfirmed(new DocumentConfirmedEvent($document)); - $this->assertCount(0, $this->removedApplications); + $this->assertSame([], $this->removedApplications); + $this->assertSame([], $this->dispatchedEvents); } - public function testInvalidatesApplicationCompletelyWithinConfirmedPeriod(): void + public function testIgnoresTeamersAllowedToHaveOverlappingApplications(): void + { + $document = $this->createContract( + new CarbonPeriod('2025-01-10', '2025-01-20'), + $this->createTeamer(allowOverlapping: true) + ); + + $this->applicationRepository->expects($this->never())->method('findOverlappingForTeamer'); + $this->entityManager->expects($this->never())->method('flush'); + + $this->listener->onDocumentConfirmed(new DocumentConfirmedEvent($document)); + + $this->assertSame([], $this->removedApplications); + } + + public function testIgnoresAssignmentsWithoutEffectivePeriod(): void + { + $document = $this->createContract(null, $this->createTeamer()); + + $this->applicationRepository->expects($this->never())->method('findOverlappingForTeamer'); + $this->entityManager->expects($this->never())->method('flush'); + + $this->listener->onDocumentConfirmed(new DocumentConfirmedEvent($document)); + + $this->assertSame([], $this->removedApplications); + } + + public function testDoesNotFlushWhenNothingOverlaps(): void + { + $teamer = $this->createTeamer(); + $document = $this->createContract(new CarbonPeriod('2025-01-10', '2025-01-20'), $teamer); + + $this->applicationRepository + ->method('findOverlappingForTeamer') + ->willReturn([]) + ; + + $this->entityManager->expects($this->never())->method('flush'); + + $this->listener->onDocumentConfirmed(new DocumentConfirmedEvent($document)); + + $this->assertSame([], $this->removedApplications); + $this->assertSame([], $this->dispatchedEvents); + } + + public function testQueriesOverlappingApplicationsForTheTeamerAndTheEffectivePeriod(): void + { + $teamer = $this->createTeamer(); + $period = new CarbonPeriod('2025-01-10', '2025-01-20'); + $document = $this->createContract($period, $teamer); + + $this->applicationRepository + ->expects($this->once()) + ->method('findOverlappingForTeamer') + ->with($teamer, $period) + ->willReturn([]) + ; + + $this->listener->onDocumentConfirmed(new DocumentConfirmedEvent($document)); + } + + public function testRemovesEveryOverlappingApplicationAndFlushesOnce(): void + { + $teamer = $this->createTeamer(); + $document = $this->createContract(new CarbonPeriod('2025-01-10', '2025-01-20'), $teamer); + + $applications = [$this->createApplication(), $this->createApplication(), $this->createApplication()]; + + $this->applicationRepository + ->method('findOverlappingForTeamer') + ->willReturn($applications) + ; + + $this->entityManager->expects($this->once())->method('flush'); + + $this->listener->onDocumentConfirmed(new DocumentConfirmedEvent($document)); + + $this->assertSame($applications, $this->removedApplications); + } + + public function testAnnouncesEveryRemovalWithAnApplicationDeletedEvent(): void + { + $teamer = $this->createTeamer(); + $document = $this->createContract(new CarbonPeriod('2025-01-10', '2025-01-20'), $teamer); + + $applications = [$this->createApplication(), $this->createApplication()]; + + $this->applicationRepository + ->method('findOverlappingForTeamer') + ->willReturn($applications) + ; + + $this->listener->onDocumentConfirmed(new DocumentConfirmedEvent($document)); + + $this->assertCount(2, $this->dispatchedEvents); + + foreach ($this->dispatchedEvents as $index => $event) { + $this->assertInstanceOf(ApplicationDeletedEvent::class, $event); + $this->assertSame($applications[$index], $event->getApplication()); + } + } + + private function createTeamer(bool $allowOverlapping = false): Teamer&MockObject { $teamer = $this->createMock(Teamer::class); - $confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20'); - $overlappingApplication = $this->createMockApplication('2025-01-12', '2025-01-18'); + $teamer->method('isAllowOverlappingApplications')->willReturn($allowOverlapping); + $teamer->method('getUuid')->willReturn('teamer-uuid'); - $document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer); - $this->setupQueryMock($teamer, $confirmedPeriod, [$overlappingApplication]); - - $event = new DocumentConfirmedEvent($document); - $this->listener->onDocumentConfirmed($event); - - $this->assertCount(1, $this->removedApplications); - $this->assertSame($overlappingApplication, $this->removedApplications[0]); + return $teamer; } - public function testInvalidatesApplicationStartingBeforeAndOverlapping(): void + private function createContract(?CarbonPeriod $period, Teamer $teamer): Upload&MockObject { - $teamer = $this->createMock(Teamer::class); - $confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20'); - $overlappingApplication = $this->createMockApplication('2025-01-05', '2025-01-15'); - - $document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer); - $this->setupQueryMock($teamer, $confirmedPeriod, [$overlappingApplication]); - - $event = new DocumentConfirmedEvent($document); - $this->listener->onDocumentConfirmed($event); - - $this->assertCount(1, $this->removedApplications); - } - - public function testInvalidatesApplicationEndingAfterAndOverlapping(): void - { - $teamer = $this->createMock(Teamer::class); - $confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20'); - $overlappingApplication = $this->createMockApplication('2025-01-15', '2025-01-25'); - - $document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer); - $this->setupQueryMock($teamer, $confirmedPeriod, [$overlappingApplication]); - - $event = new DocumentConfirmedEvent($document); - $this->listener->onDocumentConfirmed($event); - - $this->assertCount(1, $this->removedApplications); - } - - public function testInvalidatesApplicationSpanningEntireConfirmedPeriod(): void - { - $teamer = $this->createMock(Teamer::class); - $confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20'); - $spanningApplication = $this->createMockApplication('2025-01-05', '2025-01-25'); - - $document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer); - $this->setupQueryMock($teamer, $confirmedPeriod, [$spanningApplication]); - - $event = new DocumentConfirmedEvent($document); - $this->listener->onDocumentConfirmed($event); - - $this->assertCount(1, $this->removedApplications); - } - - public function testInvalidatesApplicationWithExactSameDates(): void - { - $teamer = $this->createMock(Teamer::class); - $confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20'); - $sameApplication = $this->createMockApplication('2025-01-10', '2025-01-20'); - - $document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer); - $this->setupQueryMock($teamer, $confirmedPeriod, [$sameApplication]); - - $event = new DocumentConfirmedEvent($document); - $this->listener->onDocumentConfirmed($event); - - $this->assertCount(1, $this->removedApplications); - } - - public function testDoesNotInvalidateApplicationEndingBeforeConfirmedPeriod(): void - { - $teamer = $this->createMock(Teamer::class); - $confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20'); - - $document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer); - $this->setupQueryMock($teamer, $confirmedPeriod, []); - - $event = new DocumentConfirmedEvent($document); - $this->listener->onDocumentConfirmed($event); - - $this->assertCount(0, $this->removedApplications); - } - - public function testDoesNotInvalidateApplicationStartingAfterConfirmedPeriod(): void - { - $teamer = $this->createMock(Teamer::class); - $confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20'); - - $document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer); - $this->setupQueryMock($teamer, $confirmedPeriod, []); - - $event = new DocumentConfirmedEvent($document); - $this->listener->onDocumentConfirmed($event); - - $this->assertCount(0, $this->removedApplications); - } - - public function testInvalidatesMultipleOverlappingApplications(): void - { - $teamer = $this->createMock(Teamer::class); - $confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20'); - - $application1 = $this->createMockApplication('2025-01-12', '2025-01-14'); - $application2 = $this->createMockApplication('2025-01-15', '2025-01-18'); - $application3 = $this->createMockApplication('2025-01-08', '2025-01-25'); - - $document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer); - $this->setupQueryMock($teamer, $confirmedPeriod, [$application1, $application2, $application3]); - - $event = new DocumentConfirmedEvent($document); - $this->listener->onDocumentConfirmed($event); - - $this->assertCount(3, $this->removedApplications); - } - - public function testDoesNotInvalidateApplicationTouchingOnBoundary(): void - { - $teamer = $this->createMock(Teamer::class); - $confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20'); - - $document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer); - $this->setupQueryMock($teamer, $confirmedPeriod, []); - - $event = new DocumentConfirmedEvent($document); - $this->listener->onDocumentConfirmed($event); - - $this->assertCount(0, $this->removedApplications); - } - - public function testDoesNotInvalidateRejectedApplications(): void - { - $teamer = $this->createMock(Teamer::class); - $confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20'); - - $document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer); - $this->setupQueryMock($teamer, $confirmedPeriod, []); - - $event = new DocumentConfirmedEvent($document); - $this->listener->onDocumentConfirmed($event); - - $this->assertCount(0, $this->removedApplications); - } - - public function testDoesNotInvalidateApplicationEndingOnSameDayAsConfirmedStart(): void - { - $teamer = $this->createMock(Teamer::class); - $confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20'); - - $document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer); - $this->setupQueryMock($teamer, $confirmedPeriod, []); - - $event = new DocumentConfirmedEvent($document); - $this->listener->onDocumentConfirmed($event); - - $this->assertCount(0, $this->removedApplications); - } - - public function testDoesNotInvalidateApplicationStartingOnSameDayAsConfirmedEnd(): void - { - $teamer = $this->createMock(Teamer::class); - $confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20'); - - $document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer); - $this->setupQueryMock($teamer, $confirmedPeriod, []); - - $event = new DocumentConfirmedEvent($document); - $this->listener->onDocumentConfirmed($event); - - $this->assertCount(0, $this->removedApplications); - } - - public function testInvalidatesApplicationWithOneDayActualOverlap(): void - { - $teamer = $this->createMock(Teamer::class); - $confirmedPeriod = new CarbonPeriod('2025-01-10', '2025-01-20'); - $overlappingApplication = $this->createMockApplication('2025-01-05', '2025-01-11'); - - $document = $this->createMockContractWithPeriod($confirmedPeriod, $teamer); - $this->setupQueryMock($teamer, $confirmedPeriod, [$overlappingApplication]); - - $event = new DocumentConfirmedEvent($document); - $this->listener->onDocumentConfirmed($event); - - $this->assertCount(1, $this->removedApplications); - } - - private function createMockUpload(string $type): Upload&MockObject - { - $upload = $this->createMock(Upload::class); - $upload->method('getType')->willReturn($type); - - return $upload; - } - - private function createMockContractWithPeriod(CarbonPeriod $period, Teamer $teamer): Upload&MockObject - { - $destination = $this->createMock(Destination::class); - $destination->method('getHotelCode')->willReturn('TST'); - $destination->method('getDateFrom')->willReturn($period->start->toDateTimeImmutable()); - $destination->method('getDateTo')->willReturn($period->end->toDateTimeImmutable()); - $assignment = $this->createMock(Assignment::class); $assignment->method('getEffectivePeriod')->willReturn($period); - $assignment->method('getDestination')->willReturn($destination); $disposition = $this->createMock(Disposition::class); $disposition->method('getAssignment')->willReturn($assignment); $disposition->method('getTeamer')->willReturn($teamer); - $upload = $this->createMock(Upload::class); - $upload->method('getType')->willReturn(Upload::TYPE_CONTRACT); - $upload->method('getDisposition')->willReturn($disposition); + $document = $this->createMock(Upload::class); + $document->method('getType')->willReturn(Upload::TYPE_CONTRACT); + $document->method('getDisposition')->willReturn($disposition); - return $upload; + return $document; } - private function createMockApplication(string $dateFrom, string $dateTo): Application&MockObject + private function createApplication(): Application&MockObject { $destination = $this->createMock(Destination::class); $destination->method('getHotelCode')->willReturn('TST'); - $destination->method('getDateFrom')->willReturn(new \DateTimeImmutable($dateFrom)); - $destination->method('getDateTo')->willReturn(new \DateTimeImmutable($dateTo)); $assignment = $this->createMock(Assignment::class); $assignment->method('getDestination')->willReturn($destination); + $assignment->method('getUuid')->willReturn('assignment-uuid'); + $assignment->method('getEffectiveDateFrom')->willReturn(new \DateTimeImmutable('2025-01-12')); + $assignment->method('getEffectiveDateTo')->willReturn(new \DateTimeImmutable('2025-01-18')); $application = $this->createMock(Application::class); + $application->method('getUuid')->willReturn('application-uuid'); $application->method('getAssignment')->willReturn($assignment); return $application; } - - private function setupQueryMock(Teamer $teamer, CarbonPeriod $period, array $applications): void - { - $query = $this->createMock(AbstractQuery::class); - $query->method('getResult')->willReturn($applications); - - $expr = $this->createMock(Expr::class); - $expr->method('andX')->willReturnSelf(); - $expr->method('orX')->willReturnSelf(); - $expr->method('eq')->willReturnSelf(); - $expr->method('lt')->willReturnSelf(); - $expr->method('gt')->willReturnSelf(); - $expr->method('neq')->willReturnSelf(); - $expr->method('isNull')->willReturnSelf(); - $expr->method('isNotNull')->willReturnSelf(); - - $queryBuilder = $this->createMock(QueryBuilder::class); - $queryBuilder->method('expr')->willReturn($expr); - $queryBuilder->method('innerJoin')->willReturnSelf(); - $queryBuilder->method('where')->willReturnSelf(); - $queryBuilder->method('setParameter')->willReturnSelf(); - $queryBuilder->method('getQuery')->willReturn($query); - - $repository = $this->createMock(ApplicationRepository::class); - $repository - ->method('createQueryBuilder') - ->with('application') - ->willReturn($queryBuilder); - - $this->entityManager - ->method('getRepository') - ->with(Application::class) - ->willReturn($repository); - } } diff --git a/tests/Repository/ApplicationRepositoryTest.php b/tests/Repository/ApplicationRepositoryTest.php new file mode 100644 index 0000000..f155089 --- /dev/null +++ b/tests/Repository/ApplicationRepositoryTest.php @@ -0,0 +1,82 @@ +createOverlappingQuery()->getDQL(); + + // an assignment overrides the date range of its destination, so both have to be considered + $this->assertStringContainsString( + '(assignment.dateFrom IS NULL AND destination.dateFrom <= :dateTo)' + .' OR (assignment.dateFrom IS NOT NULL AND assignment.dateFrom <= :dateTo)', + $dql + ); + $this->assertStringContainsString( + '(assignment.dateTo IS NULL AND destination.dateTo >= :dateFrom)' + .' OR (assignment.dateTo IS NOT NULL AND assignment.dateTo >= :dateFrom)', + $dql + ); + + // strict comparisons would let assignments that touch on a boundary pass and could never + // match a single day assignment at all + $this->assertDoesNotMatchRegularExpression('/date(From|To) [<>] :date(From|To)/', $dql); + } + + public function testOverlappingQueryExcludesRejectedApplicationsAndDeadAssignments(): void + { + $dql = $this->createOverlappingQuery()->getDQL(); + + $this->assertStringContainsString('application.teamer = :teamer', $dql); + $this->assertStringContainsString('application.status <> :application_rejected', $dql); + $this->assertStringContainsString('assignment.status <> :assignment_called_off', $dql); + $this->assertStringContainsString('assignment.deletedAt IS NULL', $dql); + $this->assertStringContainsString('destination.deletedAt IS NULL', $dql); + } + + public function testOverlappingQueryBindsThePeriodBoundaries(): void + { + $teamer = new Teamer(); + $period = new CarbonPeriod('2025-01-10', '2025-01-20'); + + $query = $this->createOverlappingQuery($teamer, $period); + + $this->assertSame($teamer, $query->getParameter('teamer')->getValue()); + $this->assertSame(Application::STATUS_REJECTED, $query->getParameter('application_rejected')->getValue()); + $this->assertSame(Assignment::STATUS_CALLED_OFF, $query->getParameter('assignment_called_off')->getValue()); + $this->assertSame('2025-01-10', $query->getParameter('dateFrom')->getValue()->format('Y-m-d')); + $this->assertSame('2025-01-20', $query->getParameter('dateTo')->getValue()->format('Y-m-d')); + } + + private function createOverlappingQuery(?Teamer $teamer = null, ?CarbonPeriod $period = null): Query + { + /** @var EntityManagerInterface $entityManager */ + $entityManager = static::getContainer()->get(EntityManagerInterface::class); + + /** @var ApplicationRepository $repository */ + $repository = $entityManager->getRepository(Application::class); + + return $repository->getOverlappingForTeamerQuery( + $teamer ?? new Teamer(), + $period ?? new CarbonPeriod('2025-01-10', '2025-01-20') + ); + } +}