281 lines
11 KiB
PHP
281 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Repository;
|
|
|
|
use App\Entity\Assignment;
|
|
use App\Entity\Disposition;
|
|
use App\Entity\Upload;
|
|
use App\Repository\DispositionRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Doctrine\ORM\Query;
|
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
|
|
|
/**
|
|
* The reminder queries carry their rules in DQL, and both of them shipped broken for well over a
|
|
* year without anyone noticing - the contract one asked for a document that is NULL *and* has a
|
|
* creation date, the invoice one compared a DATE column against a timestamp. Neither fails loudly;
|
|
* they simply return nothing, so the cron reports "no reminders" forever. These tests compile the
|
|
* queries and pin the parts that made them silently empty.
|
|
*/
|
|
class DispositionRepositoryTest extends KernelTestCase
|
|
{
|
|
/**
|
|
* The predecessor required `document IS NULL AND document.createdAt = :date` in one andX,
|
|
* which no row can satisfy. The date has to come off the disposition.
|
|
*/
|
|
public function testContractReminderAnchorsOnTheDispositionAndNotOnTheMissingDocument(): void
|
|
{
|
|
$dql = $this->contractQuery()->getDQL();
|
|
|
|
$this->assertStringContainsString('document IS NULL', $dql);
|
|
$this->assertStringContainsString('disposition.createdAt >= :dayStart', $dql);
|
|
$this->assertStringContainsString('disposition.createdAt < :dayEnd', $dql);
|
|
$this->assertStringNotContainsString('document.createdAt', $dql);
|
|
}
|
|
|
|
/**
|
|
* disposition.createdAt is a timestamp and the cron runs at 01:00, so an equality would only
|
|
* ever match a disposition created at that exact second.
|
|
*/
|
|
public function testContractReminderUsesAHalfOpenDayRange(): void
|
|
{
|
|
$query = $this->contractQuery(5);
|
|
|
|
$dayStart = $query->getParameter('dayStart')->getValue();
|
|
$dayEnd = $query->getParameter('dayEnd')->getValue();
|
|
|
|
$this->assertSame('00:00:00', $dayStart->format('H:i:s'));
|
|
$this->assertSame(
|
|
(new \DateTimeImmutable('today'))->modify('-5 days')->format('Y-m-d'),
|
|
$dayStart->format('Y-m-d')
|
|
);
|
|
$this->assertSame('1', $dayStart->diff($dayEnd)->format('%a'));
|
|
$this->assertDoesNotMatchRegularExpression('/disposition\.createdAt = :/', $query->getDQL());
|
|
}
|
|
|
|
/**
|
|
* An unfiltered join would let any other upload - a driver's licence, say - stand in for the
|
|
* contract and suppress the reminder.
|
|
*/
|
|
public function testContractReminderOnlyJoinsTheContract(): void
|
|
{
|
|
$query = $this->contractQuery();
|
|
|
|
$this->assertStringContainsString('WITH document.type = :documentType', $query->getDQL());
|
|
$this->assertSame(Upload::TYPE_CONTRACT, $query->getParameter('documentType')->getValue());
|
|
}
|
|
|
|
public function testContractReminderSkipsDeadAssignmentsAndDeletedTeamers(): void
|
|
{
|
|
$query = $this->contractQuery();
|
|
$dql = $query->getDQL();
|
|
|
|
$this->assertStringContainsString('teamer.deletedAt IS NULL', $dql);
|
|
$this->assertStringContainsString('disposition.status = :dispositionStatus', $dql);
|
|
$this->assertStringContainsString('assignment.status NOT IN(:assignmentStatus)', $dql);
|
|
|
|
$this->assertSame(Disposition::STATUS_NEW, $query->getParameter('dispositionStatus')->getValue());
|
|
$this->assertSame(
|
|
[Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED],
|
|
$query->getParameter('assignmentStatus')->getValue()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A short-notice assignment can end before the normal $deadlineDays window would even
|
|
* start. Without the short-notice branch, that disposition's reminder day falls on or
|
|
* after guardUploadContract() already blocks the upload, and gets missed by the
|
|
* assignment-end-not-yet-reached guard on the normal branch.
|
|
*/
|
|
public function testContractReminderHasAShortNoticeBranchForEarlyAssignmentEnds(): void
|
|
{
|
|
$dql = $this->contractQuery()->getDQL();
|
|
|
|
$this->assertStringContainsString(
|
|
'assignment.dateTo IS NOT NULL AND assignment.dateTo > :tomorrow',
|
|
$dql
|
|
);
|
|
$this->assertStringContainsString(
|
|
'assignment.dateTo IS NOT NULL AND assignment.dateTo = :tomorrow',
|
|
$dql
|
|
);
|
|
}
|
|
|
|
public function testContractReminderBindsTomorrowAsADateAndNotATimestamp(): void
|
|
{
|
|
$query = $this->contractQuery(5);
|
|
|
|
$this->assertSame(
|
|
(new \DateTimeImmutable('today +1 day'))->format('Y-m-d'),
|
|
$query->getParameter('tomorrow')->getValue()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The bug that killed this query: destination.dateTo and assignment.dateTo are DATE columns,
|
|
* and Doctrine binds a DateTimeImmutable as 'Y-m-d H:i:s', which a DATE never equals.
|
|
*/
|
|
public function testInvoiceReminderBindsADateAndNotATimestamp(): void
|
|
{
|
|
$value = $this
|
|
->invoiceQuery(new \DateTimeImmutable('2026-03-15 01:00:05'))
|
|
->getParameter('endDate')
|
|
->getValue()
|
|
;
|
|
|
|
$this->assertSame('2026-03-15', $value);
|
|
}
|
|
|
|
/**
|
|
* Without the IS NULL guard on the second branch, an assignment that moves the end date into
|
|
* the future still matches on its destination's date.
|
|
*/
|
|
public function testInvoiceReminderGuardsTheDestinationFallback(): void
|
|
{
|
|
$dql = $this->invoiceQuery()->getDQL();
|
|
|
|
$this->assertStringContainsString(
|
|
'assignment.dateTo IS NOT NULL AND assignment.dateTo = :endDate',
|
|
$dql
|
|
);
|
|
$this->assertStringContainsString(
|
|
'assignment.dateTo IS NULL AND destination.dateTo = :endDate',
|
|
$dql
|
|
);
|
|
}
|
|
|
|
public function testInvoiceReminderOnlyJoinsTheInvoiceAndSkipsFinishedDispositions(): void
|
|
{
|
|
$query = $this->invoiceQuery();
|
|
$dql = $query->getDQL();
|
|
|
|
$this->assertStringContainsString('WITH document.type = :documentType', $dql);
|
|
$this->assertStringContainsString('document IS NULL', $dql);
|
|
$this->assertStringContainsString('teamer.deletedAt IS NULL', $dql);
|
|
|
|
$this->assertSame(Upload::TYPE_INVOICE, $query->getParameter('documentType')->getValue());
|
|
$this->assertSame(
|
|
[Disposition::STATUS_CALLED_OFF, Disposition::STATUS_COMPLETED],
|
|
$query->getParameter('dispositionStatus')->getValue()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* findOverdueContracts() lacked the assignment-status and teamer.deletedAt filters that
|
|
* getContractReminderQuery() has, so the admin list and the reminder mail disagreed on
|
|
* which dispositions counted, despite the docblock's claim that they stay in sync.
|
|
*/
|
|
public function testOverdueContractsSharesTheReminderQueryScope(): void
|
|
{
|
|
$dql = $this->overdueContractsQuery()->getDQL();
|
|
|
|
$this->assertStringContainsString('teamer.deletedAt IS NULL', $dql);
|
|
$this->assertStringContainsString('assignment.status NOT IN(:assignmentStatus)', $dql);
|
|
}
|
|
|
|
/**
|
|
* findOverdueContracts() used to anchor on now(), while the reminder query anchors on
|
|
* midnight - the two disagreed on the boundary for part of each day. dueDate also has to
|
|
* land the day *after* the reminder day, so a disposition stays included from the same
|
|
* calendar day its reminder mail goes out rather than the day after.
|
|
*/
|
|
public function testOverdueContractsAnchorsOnTheSameDayTheReminderGoesOut(): void
|
|
{
|
|
$query = $this->overdueContractsQuery(5);
|
|
|
|
$dueDate = $query->getParameter('dueDate')->getValue();
|
|
|
|
$this->assertSame('00:00:00', $dueDate->format('H:i:s'));
|
|
$this->assertSame(
|
|
(new \DateTimeImmutable('today'))->modify('-4 days')->format('Y-m-d'),
|
|
$dueDate->format('Y-m-d')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A skip-formalities placement is still `confirmed` when this query runs: CronCommand sends
|
|
* the invoice reminders before DispositionStatusService promotes it to `completed`, so the
|
|
* status filter alone would let it through on the day after the assignment ends. The flag
|
|
* has to be part of the DQL.
|
|
*/
|
|
public function testInvoiceReminderSkipsSkipFormalitiesAssignments(): void
|
|
{
|
|
$query = $this->invoiceQuery();
|
|
|
|
$this->assertStringContainsString(
|
|
'assignment.skipFormalities = :skipFormalities',
|
|
$query->getDQL()
|
|
);
|
|
$this->assertFalse($query->getParameter('skipFormalities')->getValue());
|
|
}
|
|
|
|
/**
|
|
* Feeds the house-manager list, both dashboards' "Überfällige Feedbacks" tiles and the
|
|
* reminder mail, so one condition here covers every surface that chases a feedback.
|
|
*/
|
|
public function testPendingFeedbackSkipsSkipFormalitiesAssignments(): void
|
|
{
|
|
$query = $this->repository()->getDispositionsWithPendingFeedbackQuery();
|
|
|
|
$this->assertStringContainsString(
|
|
'assignment.skipFormalities = :skipFormalities',
|
|
$query->getDQL()
|
|
);
|
|
$this->assertFalse($query->getParameter('skipFormalities')->getValue());
|
|
}
|
|
|
|
/**
|
|
* Otherwise placements nobody is expected to rate would be counted as missingFeedbacks and
|
|
* drag every hotel's percentage down.
|
|
*/
|
|
public function testFeedbackStatisticsDoNotCountSkipFormalitiesPlacementsAsMissing(): void
|
|
{
|
|
// the by-hotel and normalized-code variants back two different statistics screens and are
|
|
// maintained as a pair, so both are pinned - dropping the condition from one only would
|
|
// make the two disagree about the same hotel
|
|
$queries = [
|
|
$this->repository()->getFeedbackStatisticsByHotelQuery(),
|
|
$this->repository()->getFeedbackStatisticsByNormalizedHotelCodeQuery(),
|
|
];
|
|
|
|
foreach ($queries as $query) {
|
|
$this->assertStringContainsString(
|
|
'assignment.skipFormalities = :skipFormalities',
|
|
$query->getDQL()
|
|
);
|
|
$this->assertFalse($query->getParameter('skipFormalities')->getValue());
|
|
}
|
|
}
|
|
|
|
private function contractQuery(int $deadlineDays = 5): Query
|
|
{
|
|
return $this->repository()->getContractReminderQuery($deadlineDays);
|
|
}
|
|
|
|
private function overdueContractsQuery(int $deadlineDays = 5): Query
|
|
{
|
|
return $this->repository()->getOverdueContractsQuery($deadlineDays);
|
|
}
|
|
|
|
private function invoiceQuery(?\DateTimeImmutable $endDate = null): Query
|
|
{
|
|
return $this
|
|
->repository()
|
|
->getInvoiceReminderQuery($endDate ?? new \DateTimeImmutable('2026-03-15'))
|
|
;
|
|
}
|
|
|
|
private function repository(): DispositionRepository
|
|
{
|
|
/** @var EntityManagerInterface $entityManager */
|
|
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
|
|
|
|
/** @var DispositionRepository $repository */
|
|
$repository = $entityManager->getRepository(Disposition::class);
|
|
|
|
return $repository;
|
|
}
|
|
}
|