fix: correctly apply due dates for contract and invoice upload reminders

This commit is contained in:
Björn Fromme
2026-08-17 11:55:02 +02:00
parent 3e08965e48
commit 489aafed3e
22 changed files with 732 additions and 156 deletions
@@ -0,0 +1,158 @@
<?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()
);
}
/**
* 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()
);
}
private function contractQuery(int $deadlineDays = 5): Query
{
return $this->repository()->getContractReminderQuery($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;
}
}