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