em = $this->createMock(EntityManagerInterface::class); $this->mailer = $this->createMock(MailerService::class); $this->twig = $this->createStub(Environment::class); $this->logger = $this->createMock(LoggerInterface::class); $this->actionService = $this->createMock(ActionService::class); } private function makeCommand(): PaymentReminderCommand { return new PaymentReminderCommand( $this->em, $this->mailer, $this->twig, $this->logger, $this->actionService, ); } private function makeAdvert(string $numOrder, ?string $email = 'client@example.com', int $daysAgo = 20): Advert { $orderNumber = new OrderNumber($numOrder); $customer = $this->createStub(Customer::class); $customer->method('getEmail')->willReturn($email); $customer->method('getFullName')->willReturn('Jean Dupont'); $advert = $this->createMock(Advert::class); $advert->method('getOrderNumber')->willReturn($orderNumber); $advert->method('getCustomer')->willReturn($customer); $advert->method('getState')->willReturn(Advert::STATE_SEND); $updatedAt = new \DateTimeImmutable('-'.$daysAgo.' days'); $advert->method('getUpdatedAt')->willReturn($updatedAt); $advert->method('getCreatedAt')->willReturn($updatedAt); return $advert; } /** * Stub the PaymentReminder QueryBuilder so getNextStep returns a specific list of already-done steps. * Also stubs the Advert repository findBy to return the provided adverts. * * @param array $adverts */ private function stubReminderRepo(array $existingSteps, array $adverts = []): void { $query = $this->createStub(Query::class); $query->method('getSingleColumnResult')->willReturn($existingSteps); $qb = $this->createStub(QueryBuilder::class); $qb->method('select')->willReturnSelf(); $qb->method('where')->willReturnSelf(); $qb->method('setParameter')->willReturnSelf(); $qb->method('getQuery')->willReturn($query); $reminderRepo = $this->createStub(EntityRepository::class); $reminderRepo->method('createQueryBuilder')->willReturn($qb); $advertRepo = $this->createStub(EntityRepository::class); $advertRepo->method('findBy')->willReturn($adverts); $this->em->method('getRepository')->willReturnCallback( fn (string $class) => match ($class) { PaymentReminder::class => $reminderRepo, default => $advertRepo, } ); } public function testNoAdvertsReturnsSuccess(): void { $repo = $this->createStub(EntityRepository::class); $repo->method('findBy')->willReturn([]); $this->em->method('getRepository')->with(Advert::class)->willReturn($repo); $this->mailer->expects($this->never())->method('sendEmail'); $tester = new CommandTester($this->makeCommand()); $tester->execute([]); $this->assertSame(0, $tester->getStatusCode()); $this->assertStringContainsString('Aucun avis de paiement en attente', $tester->getDisplay()); } public function testAdvertWithNoCustomerIsSkipped(): void { $advert = $this->createStub(Advert::class); $advert->method('getCustomer')->willReturn(null); $advertRepo = $this->createStub(EntityRepository::class); $advertRepo->method('findBy')->willReturn([$advert]); $this->em->method('getRepository')->willReturn($advertRepo); $this->mailer->expects($this->never())->method('sendEmail'); $tester = new CommandTester($this->makeCommand()); $tester->execute([]); $this->assertSame(0, $tester->getStatusCode()); $this->assertStringContainsString('relance(s) envoyee(s)', $tester->getDisplay()); } public function testAdvertWithNoEmailIsSkipped(): void { $advert = $this->makeAdvert('04/2026-00001', null, 20); $advertRepo = $this->createStub(EntityRepository::class); $advertRepo->method('findBy')->willReturn([$advert]); $this->em->method('getRepository')->willReturn($advertRepo); $this->mailer->expects($this->never())->method('sendEmail'); $tester = new CommandTester($this->makeCommand()); $tester->execute([]); $this->assertSame(0, $tester->getStatusCode()); $this->assertStringContainsString('relance(s) envoyee(s)', $tester->getDisplay()); } public function testAdvertWithNoEligibleStepIsSkipped(): void { // Only 5 days old — no step requires less than 15 days $advert = $this->makeAdvert('04/2026-00001', 'client@example.com', 5); $this->stubReminderRepo([], [$advert]); $this->mailer->expects($this->never())->method('sendEmail'); $tester = new CommandTester($this->makeCommand()); $tester->execute([]); $this->assertSame(0, $tester->getStatusCode()); $this->assertStringContainsString('0 relance(s) envoyee(s)', $tester->getDisplay()); } public function testAdvertWithAllStepsDoneIsSkipped(): void { // 30 days old — many steps eligible, but all already done $advert = $this->makeAdvert('04/2026-00001', 'client@example.com', 30); // All steps already done $allSteps = array_keys(PaymentReminder::STEPS_CONFIG); $this->stubReminderRepo($allSteps, [$advert]); $this->mailer->expects($this->never())->method('sendEmail'); $tester = new CommandTester($this->makeCommand()); $tester->execute([]); $this->assertSame(0, $tester->getStatusCode()); $this->assertStringContainsString('0 relance(s) envoyee(s)', $tester->getDisplay()); } public function testSendsFirstReminderAt15Days(): void { // 16 days old, no steps done yet -> should trigger reminder_15 $advert = $this->makeAdvert('04/2026-00001', 'client@example.com', 16); $this->stubReminderRepo([], [$advert]); $this->twig->method('render')->willReturn('

Email content

'); // Expect 2 emails: client + admin notification $this->mailer->expects($this->exactly(2))->method('sendEmail'); $this->em->method('persist'); $this->em->method('flush'); $tester = new CommandTester($this->makeCommand()); $tester->execute([]); $this->assertSame(0, $tester->getStatusCode()); $this->assertStringContainsString('1 relance(s) envoyee(s)', $tester->getDisplay()); } public function testSuspensionStepCallsSuspendCustomer(): void { // 30 days old, all steps before suspension already done $advert = $this->makeAdvert('04/2026-00001', 'client@example.com', 30); $customer = $advert->getCustomer(); // All steps before STEP_SUSPENSION_1 are done; days=30 >= 29 for suspension_1 $doneSteps = [ PaymentReminder::STEP_REMINDER_15, PaymentReminder::STEP_WARNING_10, PaymentReminder::STEP_SUSPENSION_WARNING_5, PaymentReminder::STEP_FINAL_REMINDER_3, ]; $this->stubReminderRepo($doneSteps, [$advert]); $this->twig->method('render')->willReturn('

Email

'); $this->mailer->method('sendEmail'); $this->em->method('persist'); $this->em->method('flush'); $this->actionService->expects($this->once()) ->method('suspendCustomer') ->with($customer, $this->stringContains('Impaye')); $tester = new CommandTester($this->makeCommand()); $tester->execute([]); $this->assertSame(0, $tester->getStatusCode()); $this->assertStringContainsString('1 relance(s) envoyee(s)', $tester->getDisplay()); } public function testTerminationWarningStepCallsDisableCustomer(): void { // 46 days old -> termination_warning step (>= 45 days) $advert = $this->makeAdvert('04/2026-00001', 'client@example.com', 46); $customer = $advert->getCustomer(); // All earlier steps done $doneSteps = [ PaymentReminder::STEP_REMINDER_15, PaymentReminder::STEP_WARNING_10, PaymentReminder::STEP_SUSPENSION_WARNING_5, PaymentReminder::STEP_FINAL_REMINDER_3, PaymentReminder::STEP_SUSPENSION_1, PaymentReminder::STEP_FORMAL_NOTICE, ]; $this->stubReminderRepo($doneSteps, [$advert]); $this->twig->method('render')->willReturn('

Email

'); $this->mailer->method('sendEmail'); $this->em->method('persist'); $this->em->method('flush'); $this->actionService->expects($this->once()) ->method('disableCustomer') ->with($customer, $this->stringContains('Pre-resiliation')); $tester = new CommandTester($this->makeCommand()); $tester->execute([]); $this->assertSame(0, $tester->getStatusCode()); } public function testTerminationStepCallsMarkForDeletion(): void { // 61 days old -> termination_30 step (>= 60 days) $advert = $this->makeAdvert('04/2026-00001', 'client@example.com', 61); $customer = $advert->getCustomer(); // All earlier steps done $doneSteps = [ PaymentReminder::STEP_REMINDER_15, PaymentReminder::STEP_WARNING_10, PaymentReminder::STEP_SUSPENSION_WARNING_5, PaymentReminder::STEP_FINAL_REMINDER_3, PaymentReminder::STEP_SUSPENSION_1, PaymentReminder::STEP_FORMAL_NOTICE, PaymentReminder::STEP_TERMINATION_WARNING, ]; $this->stubReminderRepo($doneSteps, [$advert]); $this->twig->method('render')->willReturn('

Email

'); $this->mailer->method('sendEmail'); $this->em->method('persist'); $this->em->expects($this->atLeastOnce())->method('flush'); $this->actionService->expects($this->once()) ->method('markForDeletion') ->with($customer, $this->stringContains('Resiliation')); // setState(STATE_CANCEL) should be called on the advert $advert->expects($this->once())->method('setState')->with(Advert::STATE_CANCEL); $tester = new CommandTester($this->makeCommand()); $tester->execute([]); $this->assertSame(0, $tester->getStatusCode()); } public function testFormalNoticeStepSendsEmailOnly(): void { // 32 days old -> formal_notice step (>= 31 days), all earlier steps done $advert = $this->makeAdvert('04/2026-00001', 'client@example.com', 32); $doneSteps = [ PaymentReminder::STEP_REMINDER_15, PaymentReminder::STEP_WARNING_10, PaymentReminder::STEP_SUSPENSION_WARNING_5, PaymentReminder::STEP_FINAL_REMINDER_3, PaymentReminder::STEP_SUSPENSION_1, ]; $this->stubReminderRepo($doneSteps, [$advert]); $this->twig->method('render')->willReturn('

Email

'); $this->em->method('persist'); $this->em->method('flush'); // Expect 2 emails: client (mise en demeure) + admin notification $this->mailer->expects($this->exactly(2))->method('sendEmail'); // No ActionService calls expected for formal_notice $this->actionService->expects($this->never())->method('suspendCustomer'); $this->actionService->expects($this->never())->method('disableCustomer'); $this->actionService->expects($this->never())->method('markForDeletion'); $tester = new CommandTester($this->makeCommand()); $tester->execute([]); $this->assertSame(0, $tester->getStatusCode()); $this->assertStringContainsString('1 relance(s) envoyee(s)', $tester->getDisplay()); } public function testExceptionInStepIsLoggedAndContinues(): void { $advert = $this->makeAdvert('04/2026-00001', 'client@example.com', 20); $this->stubReminderRepo([], [$advert]); // Twig throws an exception when rendering $this->twig->method('render')->willThrowException(new \RuntimeException('Twig error')); $this->logger->expects($this->once())->method('error'); $tester = new CommandTester($this->makeCommand()); $tester->execute([]); $this->assertSame(0, $tester->getStatusCode()); $this->assertStringContainsString('0 relance(s) envoyee(s)', $tester->getDisplay()); $this->assertStringContainsString('Erreur', $tester->getDisplay()); } }