repository = $this->createMock(SeasonAssignmentTaskRepository::class); $this->mailer = $this->createMock(Mailer::class); $this->service = new SeasonTaskReminderService( $this->repository, new SeasonTaskDeadlineResolver('-1 month'), new SeasonAssignmentLabeler( new SeasonCalendar('11-01', '04-01'), new HouseCatalog(['AGR' => 'Rotbach']) ), new SeasonCalendar('11-01', '04-01'), $this->mailer, new NullLogger(), ); } public function testTheSummaryFiresOnTheDeadlineDay(): void { $this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]); $this->mailer ->expects($this->once()) ->method('createAndSendEmail') ->with( $this->callback(static function (array $context): bool { $entries = reset($context['violations']); return 1 === count($entries) && 1 === $entries[0]['level']; }), $this->anything() ) ; $this->service->sendDeadlineSummary(new \DateTimeImmutable('2026-10-01')); } /** * The summary is one office mail, not a copy per admin: it passes no recipient at all and so * lands on the Mailer default, APP_DEFAULT_EMAIL_TO. */ public function testTheSummaryGoesOutOnceToTheDefaultAddress(): void { $this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]); $this->mailer ->expects($this->once()) ->method('createAndSendEmail') ->with( $this->anything(), $this->callback( static fn (array $options): bool => false === array_key_exists('to', $options) ) ) ; $this->service->sendDeadlineSummary(new \DateTimeImmutable('2026-10-01')); } public function testTheSummaryDoesNotFireTheDayAfter(): void { $this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]); $this->mailer->expects($this->never())->method('createAndSendEmail'); $this->service->sendDeadlineSummary(new \DateTimeImmutable('2026-10-02')); } /** * Frist 2 defaults to Frist 1, so the two coincide unless the office moves one. The entry is * then reported once and as Frist 1 - a second, identical line would be pure noise. */ public function testCoincidingDeadlinesAreReportedOnceAsTheFirst(): void { $this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]); $levels = []; $this->mailer ->method('createAndSendEmail') ->willReturnCallback(static function (array $context) use (&$levels): void { foreach (reset($context['violations']) as $entry) { $levels[] = $entry['level']; } }) ; $this->service->sendDeadlineSummary(new \DateTimeImmutable('2026-10-01')); $this->assertSame([1], $levels); } public function testAPushedOutSecondDeadlineGetsItsOwnDay(): void { $task = $this->task('2026-11-01')->setDeadlineOverride2(new \DateTimeImmutable('2026-10-15')); $this->repository->method('findOpen')->willReturn([$task]); $levels = []; $this->mailer ->method('createAndSendEmail') ->willReturnCallback(static function (array $context) use (&$levels): void { foreach (reset($context['violations']) as $entry) { $levels[] = $entry['level']; } }) ; $this->service->sendDeadlineSummary(new \DateTimeImmutable('2026-10-15')); $this->assertSame([2], $levels); } public function testTheDigestOnlyGoesOutOnMondays(): void { $this->repository->expects($this->never())->method('findOpen'); $this->mailer->expects($this->never())->method('createAndSendText'); // 2026-10-06 is a tuesday $this->service->sendTeamerDigest(new \DateTimeImmutable('2026-10-06')); } public function testTheDigestListsTheOpenReturnEntries(): void { $this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]); $this->mailer ->expects($this->once()) ->method('createAndSendText') ->with( EmailTextKey::REMINDER_SEASON_TASKS, $this->callback(static fn (array $placeholders): bool => SeasonTask::CONTRACT->label() === $placeholders['tasks']), $this->anything() ) ; $this->service->sendTeamerDigest($this->monday()); } public function testAnOfficeOnlyEntryNeverReachesTheTeamer(): void { $this->repository ->method('findOpen') ->willReturn([$this->task('2026-11-01', SeasonTask::SAAS_REGISTERED)]) ; $this->mailer->expects($this->never())->method('createAndSendText'); $this->service->sendTeamerDigest($this->monday()); } private function monday(): \DateTimeImmutable { // 2026-10-05 is a monday, and inside the season the digest is scoped to return new \DateTimeImmutable('2026-10-05'); } private function task(string $dateFrom, SeasonTask $definition = SeasonTask::CONTRACT): SeasonAssignmentTask { $teamer = new Teamer(); $teamer->setCommunication((new Communication())->setEmail('teamer@example.org')); $seasonAssignment = (new SeasonAssignment()) ->setTeamer($teamer) ->setDateFrom(new \DateTimeImmutable($dateFrom)) ->setDateTo(new \DateTimeImmutable($dateFrom.' +5 months')) ->setHotelCode('AGR') ; $task = (new SeasonAssignmentTask())->setTaskKey($definition->value); $seasonAssignment->addTask($task); return $task; } }