fix: adjust detection of overlapping applications

addresses #869ar02xk
This commit is contained in:
Björn Fromme
2026-08-13 18:47:20 +02:00
parent bdd06f0187
commit 804dab335f
5 changed files with 342 additions and 322 deletions
@@ -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<Application> $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
);
}
}
}
+62
View File
@@ -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<Application>
*/
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');
@@ -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')
)
)
))
@@ -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<Application> */
private array $removedApplications = [];
/** @var array<Event> */
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);
}
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Teamer;
use App\Repository\ApplicationRepository;
use Carbon\CarbonPeriod;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* The overlap query behind the automatic purge of applications is expressed in DQL, so its semantics
* cannot be covered by mocking the query builder - doing so is what let a wrong comparison ship
* unnoticed. These tests compile the query instead and pin the parts that carry the semantics.
*/
class ApplicationRepositoryTest extends KernelTestCase
{
public function testOverlappingQueryUsesInclusiveBoundariesAndEffectiveDates(): void
{
$dql = $this->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')
);
}
}