repository = $this->createMock(DispositionRepository::class); $this->mailer = $this->createMock(Mailer::class); $this->service = new UploadReminderService( $this->repository, $this->mailer, new NullLogger(), self::CONTRACT_DEADLINE_DAYS, self::INVOICE_DEADLINE_DAYS, ); } public function testTheContractReminderAsksForTheConfiguredPeriod(): void { $this->repository ->expects($this->once()) ->method('findDispositionsForContractReminder') ->with(self::CONTRACT_DEADLINE_DAYS) ->willReturn([]) ; $this->assertSame( 'No due contract upload reminders to be sent to teamers', $this->service->sendContractUploadReminders() ); } public function testTheContractReminderMailsEachTeamerOnce(): void { $this->repository ->method('findDispositionsForContractReminder') ->willReturn([$this->createDisposition(), $this->createDisposition()]) ; $this->mailer ->expects($this->exactly(2)) ->method('createAndSendText') ->with(EmailTextKey::REMINDER_CONTRACT_UPLOAD, $this->anything(), $this->anything()) ; $this->assertSame( 'Sent 2 due contract upload reminders to teamers', $this->service->sendContractUploadReminders() ); } /** * The first reminder goes out the day after the assignment ended, the second on the last day * of the upload period. Both are anchored on the end date, so each targets exactly one day and * the cron can run twice without mailing anyone twice. */ public function testTheTwoInvoiceRemindersTargetTheDayAfterTheEndAndTheDeadline(): void { $today = new \DateTimeImmutable('today'); $requested = []; $this->repository ->expects($this->exactly(2)) ->method('findDispositionsForInvoiceReminder') ->willReturnCallback(function (\DateTimeImmutable $endDate) use (&$requested): array { $requested[] = $endDate->format('Y-m-d'); return []; }) ; $this->service->sendInvoiceUploadReminders(); $this->assertSame([ $today->modify('-1 day')->format('Y-m-d'), $today->modify('-'.self::INVOICE_DEADLINE_DAYS.' days')->format('Y-m-d'), ], $requested); } /** * The second mail must not repeat "within the next 14 days" - by then the period is over. */ public function testTheSecondInvoiceReminderUsesTheFinalWording(): void { $this->repository ->method('findDispositionsForInvoiceReminder') ->willReturnOnConsecutiveCalls([$this->createDisposition()], [$this->createDisposition()]) ; $keys = []; $this->mailer ->method('createAndSendText') ->willReturnCallback(function (EmailTextKey $key) use (&$keys): void { $keys[] = $key; }) ; $message = $this->service->sendInvoiceUploadReminders(); $this->assertSame([ EmailTextKey::REMINDER_INVOICE_UPLOAD, EmailTextKey::REMINDER_INVOICE_UPLOAD_FINAL, ], $keys); $this->assertSame('Sent 2 due invoice upload reminders to teamers', $message); } public function testTheInvoiceReminderPassesTheDeadlineToTheWording(): void { $this->repository ->method('findDispositionsForInvoiceReminder') ->willReturnOnConsecutiveCalls([$this->createDisposition()], []) ; $this->mailer ->expects($this->once()) ->method('createAndSendText') ->with( EmailTextKey::REMINDER_INVOICE_UPLOAD, $this->callback(fn (array $placeholders): bool => self::INVOICE_DEADLINE_DAYS === $placeholders['invoiceUploadDeadlineDays'] && 'Skireise' === substr((string) $placeholders['destination'], -8)), ['to' => 'teamer@example.com'], ) ; $this->service->sendInvoiceUploadReminders(); } private function createDisposition(): Disposition { $destination = (new Destination()) ->setProduct('Skireise') ->setDateFrom(new \DateTimeImmutable('2026-03-01')) ->setDateTo(new \DateTimeImmutable('2026-03-14')) ; $assignment = (new Assignment())->setDestination($destination); $teamer = (new Teamer())->setCommunication( (new Communication())->setEmail('teamer@example.com') ); return new Disposition(new Application($assignment, $teamer)); } }