repository = $this->createMock(SeasonAssignmentTaskRepository::class); $this->userRepository = $this->createMock(UserRepository::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->userRepository, $this->mailer, new NullLogger(), ); } public function testTheSummaryFiresOnTheDeadlineDay(): void { $this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]); $this->userRepository->method('getUsersByRoles')->willReturn([$this->admin()]); $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')); } 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')]); $this->userRepository->method('getUsersByRoles')->willReturn([$this->admin()]); $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]); $this->userRepository->method('getUsersByRoles')->willReturn([$this->admin()]); $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 testAnAdminMutingNotificationsIsSkipped(): void { $this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]); $this->userRepository->method('getUsersByRoles')->willReturn([ $this->admin()->setMuteNotifications(true), ]); $this->mailer->expects($this->never())->method('createAndSendEmail'); $this->service->sendDeadlineSummary(new \DateTimeImmutable('2026-10-01')); } 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()); } /** * The flag is the office's own note and silences nothing: only ticking the entry off does. */ public function testDoneTeamerDoesNotSuppressTheDigest(): void { $task = $this->task('2026-11-01'); $task->getSeasonAssignment()->setDoneTeamer(true); $this->repository->method('findOpen')->willReturn([$task]); $this->mailer->expects($this->once())->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; } private function admin(): User { return (new User())->setEmail('office@example.org'); } }