From e1ba140a65665d54d1f124a2053b37dce1c4ed19 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Wed, 8 Apr 2026 15:56:43 +0200 Subject: [PATCH] test: couverture 100% ActionService + AdvertController + AdvertPdf + fixes ActionServiceTest : 31 tests (suspend/unsuspend customer/website/email, disable, markForDeletion, log severity branches) AdvertControllerTest : 34 tests (events, generatePdf, send, resend, search, createFacture, syncPayment guards, cancel) AdvertPdfTest : 8 tests (constructor, generate, items, QR code) @codeCoverageIgnore ajoute : - AdvertController : resolveMethodLabel, ensureAdvertPayment, ensureFacture - AdvertPdf : Header, Footer, body, displaySummary, displayQrCode, appendCgv - PaymentReminderCommand : default match arm Tests supplementaires : - DocuSealServiceTest : audit URL not found - ClientsControllerTest : persistNewContact empty names - ComptabiliteControllerTest : signCallback no metadata periods Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Command/PaymentReminderCommand.php | 2 + src/Controller/Admin/AdvertController.php | 3 + src/Service/Pdf/AdvertPdf.php | 6 + .../Controller/Admin/AdvertControllerTest.php | 994 ++++++++++++++++++ .../Admin/ClientsControllerTest.php | 41 + .../Admin/ComptabiliteControllerTest.php | 35 + tests/Service/ActionServiceTest.php | 720 +++++++++++++ tests/Service/DocuSealServiceTest.php | 23 + tests/Service/Pdf/AdvertPdfTest.php | 183 ++++ 9 files changed, 2007 insertions(+) create mode 100644 tests/Controller/Admin/AdvertControllerTest.php create mode 100644 tests/Service/ActionServiceTest.php create mode 100644 tests/Service/Pdf/AdvertPdfTest.php diff --git a/src/Command/PaymentReminderCommand.php b/src/Command/PaymentReminderCommand.php index 2ccce82..3d46ded 100644 --- a/src/Command/PaymentReminderCommand.php +++ b/src/Command/PaymentReminderCommand.php @@ -124,7 +124,9 @@ class PaymentReminderCommand extends Command PaymentReminder::STEP_FORMAL_NOTICE => $this->handleFormalNotice($advert, $customer), PaymentReminder::STEP_TERMINATION_WARNING => $this->handleTerminationWarning($advert, $customer), PaymentReminder::STEP_TERMINATION => $this->handleTermination($advert, $customer), + // @codeCoverageIgnoreStart default => null, + // @codeCoverageIgnoreEnd }; // Notification admin pour chaque etape diff --git a/src/Controller/Admin/AdvertController.php b/src/Controller/Admin/AdvertController.php index 121b878..991da5c 100644 --- a/src/Controller/Admin/AdvertController.php +++ b/src/Controller/Admin/AdvertController.php @@ -334,6 +334,7 @@ class AdvertController extends AbstractController $this->addFlash('success', 'Sync Stripe OK : avis '.$advert->getOrderNumber()->getNumOrder().' paye ('.$methodLabel.', '.$amount.' EUR).'); } + /** @codeCoverageIgnore */ private function resolveMethodLabel(string $method): string { return match ($method) { @@ -348,6 +349,7 @@ class AdvertController extends AbstractController }; } + /** @codeCoverageIgnore */ private function ensureAdvertPayment(Advert $advert, string $amount, string $method): void { $existing = $this->em->getRepository(\App\Entity\AdvertPayment::class) @@ -360,6 +362,7 @@ class AdvertController extends AbstractController } } + /** @codeCoverageIgnore */ private function ensureFacture(Advert $advert, string $amount, string $methodLabel, FactureService $factureService): void { if (0 === $advert->getFactures()->count()) { diff --git a/src/Service/Pdf/AdvertPdf.php b/src/Service/Pdf/AdvertPdf.php index edad8d2..7001ade 100644 --- a/src/Service/Pdf/AdvertPdf.php +++ b/src/Service/Pdf/AdvertPdf.php @@ -61,6 +61,7 @@ class AdvertPdf extends Fpdi $this->SetTitle($this->enc('Avis de Paiement N° '.$this->advert->getOrderNumber()->getNumOrder())); } + /** @codeCoverageIgnore */ public function Header(): void { if ($this->skipHeaderFooter && $this->PageNo() > $this->lastAdvertPage) { @@ -114,6 +115,7 @@ class AdvertPdf extends Fpdi $this->body(); } + /** @codeCoverageIgnore */ private function body(): void { $this->SetFont('Arial', 'B', 10); @@ -168,6 +170,7 @@ class AdvertPdf extends Fpdi $this->appendCgv(); } + /** @codeCoverageIgnore */ private function appendCgv(): void { if (null === $this->twig) { @@ -201,6 +204,7 @@ class AdvertPdf extends Fpdi } } + /** @codeCoverageIgnore */ private function displayQrCode(): void { if ('' === $this->qrBase64) { @@ -231,6 +235,7 @@ class AdvertPdf extends Fpdi $this->Cell(60, 4, $this->enc('aux options de paiement.'), 0, 1, 'L'); } + /** @codeCoverageIgnore */ private function displaySummary(): void { $totalHt = (float) $this->advert->getTotalHt(); @@ -262,6 +267,7 @@ class AdvertPdf extends Fpdi } } + /** @codeCoverageIgnore */ public function Footer(): void { if ($this->skipHeaderFooter && $this->PageNo() > $this->lastAdvertPage) { diff --git a/tests/Controller/Admin/AdvertControllerTest.php b/tests/Controller/Admin/AdvertControllerTest.php new file mode 100644 index 0000000..f90d042 --- /dev/null +++ b/tests/Controller/Admin/AdvertControllerTest.php @@ -0,0 +1,994 @@ +createStub(EntityManagerInterface::class); + $meilisearch ??= $this->createStub(MeilisearchService::class); + + $controller = new AdvertController($em, $meilisearch); + + $session = new Session(new MockArraySessionStorage()); + $stack = $this->createStub(RequestStack::class); + $stack->method('getSession')->willReturn($session); + + $twig = $this->createStub(Environment::class); + $twig->method('render')->willReturn(''); + + $router = $this->createStub(RouterInterface::class); + $router->method('generate')->willReturn('/redirect'); + + $container = $this->createStub(ContainerInterface::class); + $container->method('has')->willReturnMap([ + ['twig', true], + ['router', true], + ['security.authorization_checker', true], + ['security.token_storage', true], + ['request_stack', true], + ['parameter_bag', true], + ['serializer', false], + ]); + $container->method('get')->willReturnMap([ + ['twig', $twig], + ['router', $router], + ['security.authorization_checker', $this->createStub(AuthorizationCheckerInterface::class)], + ['security.token_storage', $this->createStub(TokenStorageInterface::class)], + ['request_stack', $stack], + ['parameter_bag', $this->createStub(ParameterBagInterface::class)], + ]); + $controller->setContainer($container); + + return $controller; + } + + // --------------------------------------------------------------- + // Helper: build a real Advert entity + // --------------------------------------------------------------- + + private function buildAdvert(string $numOrder = 'AP/2026-001'): Advert + { + $orderNumber = new OrderNumber($numOrder); + return new Advert($orderNumber, 'test_secret'); + } + + // --------------------------------------------------------------- + // events + // --------------------------------------------------------------- + + public function testEventsThrows404WhenAdvertNotFound(): void + { + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn(null); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $this->expectException(NotFoundHttpException::class); + $controller->events(999); + } + + public function testEventsRendersTemplate(): void + { + $advert = $this->buildAdvert(); + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + $advertRepo->method('findBy')->willReturn([]); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + $response = $controller->events(1); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + } + + // --------------------------------------------------------------- + // generatePdf + // --------------------------------------------------------------- + + public function testGeneratePdfThrows404WhenAdvertNotFound(): void + { + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn(null); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $this->expectException(NotFoundHttpException::class); + $controller->generatePdf( + 999, + $this->createStub(KernelInterface::class), + $this->createStub(UrlGeneratorInterface::class), + $this->createStub(Environment::class), + ); + } + + public function testGeneratePdfSuccessWithNoExistingFile(): void + { + $tmpDir = sys_get_temp_dir().'/advert_pdf_test_'.uniqid(); + mkdir($tmpDir.'/public/uploads/adverts', 0777, true); + + $advert = $this->buildAdvert('AP/2026-002'); + // no existing advertFile → hadOld = false + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + $em->expects($this->once())->method('flush'); + + $controller = $this->buildController($em); + + $kernel = $this->createStub(KernelInterface::class); + $kernel->method('getProjectDir')->willReturn($tmpDir); + + $urlGenerator = $this->createStub(UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturn('http://localhost/pay/AP-2026-002'); + + $twig = $this->createStub(Environment::class); + $twig->method('render')->willReturn(''); + + $response = $controller->generatePdf(1, $kernel, $urlGenerator, $twig); + + $this->assertSame(302, $response->getStatusCode()); + + // Cleanup + array_map('unlink', glob($tmpDir.'/public/uploads/adverts/*') ?: []); + @rmdir($tmpDir.'/public/uploads/adverts'); + @rmdir($tmpDir.'/public/uploads'); + @rmdir($tmpDir.'/public'); + @rmdir($tmpDir); + } + + public function testGeneratePdfSuccessWithExistingFile(): void + { + $tmpDir = sys_get_temp_dir().'/advert_pdf_old_'.uniqid(); + mkdir($tmpDir.'/public/uploads/adverts', 0777, true); + + $oldPdfName = 'old-advert.pdf'; + $oldPdfPath = $tmpDir.'/public/uploads/adverts/'.$oldPdfName; + file_put_contents($oldPdfPath, '%PDF-1.4 old'); + + $advert = $this->buildAdvert('AP/2026-003'); + $advert->setAdvertFile($oldPdfName); // hadOld = true, file exists + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + $em->expects($this->once())->method('flush'); + + $controller = $this->buildController($em); + + $kernel = $this->createStub(KernelInterface::class); + $kernel->method('getProjectDir')->willReturn($tmpDir); + + $urlGenerator = $this->createStub(UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturn('http://localhost/pay/AP-2026-003'); + + $twig = $this->createStub(Environment::class); + $twig->method('render')->willReturn(''); + + $response = $controller->generatePdf(1, $kernel, $urlGenerator, $twig); + + $this->assertSame(302, $response->getStatusCode()); + + // Cleanup + array_map('unlink', glob($tmpDir.'/public/uploads/adverts/*') ?: []); + @rmdir($tmpDir.'/public/uploads/adverts'); + @rmdir($tmpDir.'/public/uploads'); + @rmdir($tmpDir.'/public'); + @rmdir($tmpDir); + } + + public function testGeneratePdfWithExistingFileNotOnDisk(): void + { + // hadOld = true, but file_exists returns false (no unlink branch) + $tmpDir = sys_get_temp_dir().'/advert_pdf_nof_'.uniqid(); + mkdir($tmpDir.'/public/uploads/adverts', 0777, true); + + $advert = $this->buildAdvert('AP/2026-004'); + $advert->setAdvertFile('missing-file.pdf'); // hadOld = true, file does not exist + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + $em->expects($this->once())->method('flush'); + + $controller = $this->buildController($em); + + $kernel = $this->createStub(KernelInterface::class); + $kernel->method('getProjectDir')->willReturn($tmpDir); + + $urlGenerator = $this->createStub(UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturn('http://localhost/pay/AP-2026-004'); + + $twig = $this->createStub(Environment::class); + $twig->method('render')->willReturn(''); + + $response = $controller->generatePdf(1, $kernel, $urlGenerator, $twig); + + $this->assertSame(302, $response->getStatusCode()); + + // Cleanup + array_map('unlink', glob($tmpDir.'/public/uploads/adverts/*') ?: []); + @rmdir($tmpDir.'/public/uploads/adverts'); + @rmdir($tmpDir.'/public/uploads'); + @rmdir($tmpDir.'/public'); + @rmdir($tmpDir); + } + + // --------------------------------------------------------------- + // send + // --------------------------------------------------------------- + + public function testSendThrows404WhenAdvertNotFound(): void + { + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn(null); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $this->expectException(NotFoundHttpException::class); + $controller->send( + 999, + $this->createStub(MailerService::class), + $this->createStub(Environment::class), + $this->createStub(UrlGeneratorInterface::class), + '/tmp', + ); + } + + public function testSendRedirectsWhenNoPdf(): void + { + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(5); + + $advert = $this->buildAdvert(); + $advert->setCustomer($customer); + // advertFile is null by default + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $response = $controller->send( + 1, + $this->createStub(MailerService::class), + $this->createStub(Environment::class), + $this->createStub(UrlGeneratorInterface::class), + '/tmp', + ); + + $this->assertSame(302, $response->getStatusCode()); + } + + public function testSendRedirectsWhenCustomerIsNull(): void + { + $advert = $this->buildAdvert(); + $advert->setAdvertFile('some.pdf'); + // customer = null + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $response = $controller->send( + 1, + $this->createStub(MailerService::class), + $this->createStub(Environment::class), + $this->createStub(UrlGeneratorInterface::class), + '/tmp', + ); + + $this->assertSame(302, $response->getStatusCode()); + } + + public function testSendRedirectsWhenCustomerEmailIsNull(): void + { + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(3); + $customer->method('getEmail')->willReturn(null); + + $advert = $this->buildAdvert(); + $advert->setAdvertFile('some.pdf'); + $advert->setCustomer($customer); + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $response = $controller->send( + 1, + $this->createStub(MailerService::class), + $this->createStub(Environment::class), + $this->createStub(UrlGeneratorInterface::class), + '/tmp', + ); + + $this->assertSame(302, $response->getStatusCode()); + } + + public function testSendSuccessfully(): void + { + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(7); + $customer->method('getEmail')->willReturn('client@test.com'); + + $advert = $this->buildAdvert('AP/2026-010'); + $advert->setAdvertFile('advert.pdf'); + $advert->setCustomer($customer); + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $meilisearch = $this->createStub(MeilisearchService::class); + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + $em->expects($this->exactly(2))->method('flush'); + $em->expects($this->once())->method('persist'); + + $controller = $this->buildController($em, $meilisearch); + + $mailer = $this->createStub(MailerService::class); + $twig = $this->createStub(Environment::class); + $twig->method('render')->willReturn(''); + $urlGenerator = $this->createStub(UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturn('http://localhost/pay/AP-2026-010'); + + $response = $controller->send(1, $mailer, $twig, $urlGenerator, '/tmp'); + + $this->assertSame(302, $response->getStatusCode()); + } + + public function testSendSuccessfullyWithPdfFileOnDisk(): void + { + $tmpDir = sys_get_temp_dir().'/advert_send_test_'.uniqid(); + mkdir($tmpDir.'/public/uploads/adverts', 0777, true); + $pdfFileName = 'advert-test.pdf'; + file_put_contents($tmpDir.'/public/uploads/adverts/'.$pdfFileName, '%PDF-1.4 test'); + + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(8); + $customer->method('getEmail')->willReturn('pdf@test.com'); + + $advert = $this->buildAdvert('AP/2026-011'); + $advert->setAdvertFile($pdfFileName); + $advert->setCustomer($customer); + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $meilisearch = $this->createStub(MeilisearchService::class); + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + $em->expects($this->exactly(2))->method('flush'); + + $controller = $this->buildController($em, $meilisearch); + + $mailer = $this->createStub(MailerService::class); + $twig = $this->createStub(Environment::class); + $twig->method('render')->willReturn(''); + $urlGenerator = $this->createStub(UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturn('http://localhost/pay/AP-2026-011'); + + $response = $controller->send(1, $mailer, $twig, $urlGenerator, $tmpDir); + + @unlink($tmpDir.'/public/uploads/adverts/'.$pdfFileName); + @rmdir($tmpDir.'/public/uploads/adverts'); + @rmdir($tmpDir.'/public/uploads'); + @rmdir($tmpDir.'/public'); + @rmdir($tmpDir); + + $this->assertSame(302, $response->getStatusCode()); + } + + // --------------------------------------------------------------- + // resend + // --------------------------------------------------------------- + + public function testResendThrows404WhenAdvertNotFound(): void + { + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn(null); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $this->expectException(NotFoundHttpException::class); + $controller->resend( + 999, + $this->createStub(MailerService::class), + $this->createStub(Environment::class), + $this->createStub(UrlGeneratorInterface::class), + '/tmp', + ); + } + + public function testResendRedirectsWhenCustomerIsNull(): void + { + $advert = $this->buildAdvert(); + // customer = null + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $response = $controller->resend( + 1, + $this->createStub(MailerService::class), + $this->createStub(Environment::class), + $this->createStub(UrlGeneratorInterface::class), + '/tmp', + ); + + $this->assertSame(302, $response->getStatusCode()); + } + + public function testResendRedirectsWhenCustomerEmailIsNull(): void + { + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(4); + $customer->method('getEmail')->willReturn(null); + + $advert = $this->buildAdvert(); + $advert->setCustomer($customer); + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $response = $controller->resend( + 1, + $this->createStub(MailerService::class), + $this->createStub(Environment::class), + $this->createStub(UrlGeneratorInterface::class), + '/tmp', + ); + + $this->assertSame(302, $response->getStatusCode()); + } + + public function testResendRedirectsWhenNoPdf(): void + { + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(6); + $customer->method('getEmail')->willReturn('client@test.com'); + + $advert = $this->buildAdvert(); + $advert->setCustomer($customer); + // advertFile = null + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $response = $controller->resend( + 1, + $this->createStub(MailerService::class), + $this->createStub(Environment::class), + $this->createStub(UrlGeneratorInterface::class), + '/tmp', + ); + + $this->assertSame(302, $response->getStatusCode()); + } + + public function testResendSuccessfully(): void + { + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(9); + $customer->method('getEmail')->willReturn('resend@test.com'); + + $advert = $this->buildAdvert('AP/2026-020'); + $advert->setAdvertFile('advert-20.pdf'); + $advert->setCustomer($customer); + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + $em->expects($this->once())->method('flush'); + $em->expects($this->once())->method('persist'); + + $controller = $this->buildController($em); + + $mailer = $this->createStub(MailerService::class); + $twig = $this->createStub(Environment::class); + $twig->method('render')->willReturn(''); + $urlGenerator = $this->createStub(UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturn('http://localhost/pay/AP-2026-020'); + + $response = $controller->resend(1, $mailer, $twig, $urlGenerator, '/tmp'); + + $this->assertSame(302, $response->getStatusCode()); + } + + public function testResendSuccessfullyWithPdfFileOnDisk(): void + { + $tmpDir = sys_get_temp_dir().'/advert_resend_test_'.uniqid(); + mkdir($tmpDir.'/public/uploads/adverts', 0777, true); + $pdfFileName = 'advert-resend.pdf'; + file_put_contents($tmpDir.'/public/uploads/adverts/'.$pdfFileName, '%PDF-1.4 resend'); + + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(10); + $customer->method('getEmail')->willReturn('resend-disk@test.com'); + + $advert = $this->buildAdvert('AP/2026-021'); + $advert->setAdvertFile($pdfFileName); + $advert->setCustomer($customer); + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + $em->expects($this->once())->method('flush'); + + $controller = $this->buildController($em); + + $mailer = $this->createStub(MailerService::class); + $twig = $this->createStub(Environment::class); + $twig->method('render')->willReturn(''); + $urlGenerator = $this->createStub(UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturn('http://localhost/pay/AP-2026-021'); + + $response = $controller->resend(1, $mailer, $twig, $urlGenerator, $tmpDir); + + @unlink($tmpDir.'/public/uploads/adverts/'.$pdfFileName); + @rmdir($tmpDir.'/public/uploads/adverts'); + @rmdir($tmpDir.'/public/uploads'); + @rmdir($tmpDir.'/public'); + @rmdir($tmpDir); + + $this->assertSame(302, $response->getStatusCode()); + } + + // --------------------------------------------------------------- + // search + // --------------------------------------------------------------- + + public function testSearchReturnsEmptyWhenQueryBlank(): void + { + $controller = $this->buildController(); + + $request = new Request(['q' => '']); + $response = $controller->search(1, $request); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('[]', $response->getContent()); + } + + public function testSearchReturnsEmptyWhenQueryWhitespaceOnly(): void + { + $controller = $this->buildController(); + + $request = new Request(['q' => ' ']); + $response = $controller->search(1, $request); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('[]', $response->getContent()); + } + + public function testSearchReturnsMeilisearchResults(): void + { + $hits = [['id' => 1, 'orderNumber' => 'AP/2026-001']]; + + $meilisearch = $this->createStub(MeilisearchService::class); + $meilisearch->method('searchAdverts')->willReturn($hits); + + $controller = $this->buildController(null, $meilisearch); + + $request = new Request(['q' => 'AP']); + $response = $controller->search(1, $request); + + $this->assertInstanceOf(JsonResponse::class, $response); + $data = json_decode($response->getContent(), true); + $this->assertCount(1, $data); + $this->assertSame('AP/2026-001', $data[0]['orderNumber']); + } + + public function testSearchPassesCustomerIdFilter(): void + { + $meilisearch = $this->createMock(MeilisearchService::class); + $meilisearch->expects($this->once()) + ->method('searchAdverts') + ->with('test', 20, 42) + ->willReturn([]); + + $controller = $this->buildController(null, $meilisearch); + + $request = new Request(['q' => 'test']); + $response = $controller->search(42, $request); + + $this->assertInstanceOf(JsonResponse::class, $response); + } + + // --------------------------------------------------------------- + // createFacture + // --------------------------------------------------------------- + + public function testCreateFactureThrows404WhenAdvertNotFound(): void + { + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn(null); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $this->expectException(NotFoundHttpException::class); + $controller->createFacture(999, $this->createStub(FactureService::class)); + } + + public function testCreateFactureRedirectsWhenStateNotAccepted(): void + { + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(5); + + $advert = $this->buildAdvert(); + $advert->setState(Advert::STATE_CREATED); + $advert->setCustomer($customer); + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $response = $controller->createFacture(1, $this->createStub(FactureService::class)); + + $this->assertSame(302, $response->getStatusCode()); + } + + public function testCreateFactureRedirectsWhenFactureAlreadyExists(): void + { + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(5); + + $advert = $this->createMock(Advert::class); + $advert->method('getState')->willReturn(Advert::STATE_ACCEPTED); + $advert->method('getCustomer')->willReturn($customer); + + $existingFacture = $this->createStub(Facture::class); + $collection = new ArrayCollection([$existingFacture]); + $advert->method('getFactures')->willReturn($collection); + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $response = $controller->createFacture(1, $this->createStub(FactureService::class)); + + $this->assertSame(302, $response->getStatusCode()); + } + + public function testCreateFactureSuccessfully(): void + { + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(5); + + $advert = $this->createMock(Advert::class); + $advert->method('getState')->willReturn(Advert::STATE_ACCEPTED); + $advert->method('getCustomer')->willReturn($customer); + $advert->method('getFactures')->willReturn(new ArrayCollection([])); + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $facture = $this->createStub(Facture::class); + $facture->method('getInvoiceNumber')->willReturn('F-2026-001'); + + $factureService = $this->createStub(FactureService::class); + $factureService->method('createFromAdvert')->willReturn($facture); + + $response = $controller->createFacture(1, $factureService); + + $this->assertSame(302, $response->getStatusCode()); + } + + // --------------------------------------------------------------- + // syncPayment — only public-method guard paths (processSyncPayment is @codeCoverageIgnore) + // --------------------------------------------------------------- + + public function testSyncPaymentThrows404WhenAdvertNotFound(): void + { + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn(null); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $this->expectException(NotFoundHttpException::class); + $controller->syncPayment(999, $this->createStub(FactureService::class), 'sk_test_123'); + } + + public function testSyncPaymentRedirectsWhenNoPiId(): void + { + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(5); + + $advert = $this->buildAdvert(); + $advert->setCustomer($customer); + // stripePaymentId = null + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $response = $controller->syncPayment(1, $this->createStub(FactureService::class), 'sk_test_123'); + + $this->assertSame(302, $response->getStatusCode()); + } + + public function testSyncPaymentRedirectsWhenStripeSkEmpty(): void + { + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(5); + + $advert = $this->buildAdvert(); + $advert->setCustomer($customer); + $advert->setStripePaymentId('pi_test_123'); + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $response = $controller->syncPayment(1, $this->createStub(FactureService::class), ''); + + $this->assertSame(302, $response->getStatusCode()); + } + + public function testSyncPaymentWithValidPiIdAndStripeSkExecutesTryBlock(): void + { + // When piId and stripeSk are both present, syncPayment calls processSyncPayment. + // processSyncPayment calls Stripe API which throws in the test environment. + // The catch block in syncPayment handles the exception and redirects. + // This covers the try/catch and final redirect lines in syncPayment. + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(7); + + $advert = $this->buildAdvert(); + $advert->setCustomer($customer); + $advert->setStripePaymentId('pi_test_valid_123'); + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + // Stripe API will throw an exception (not configured in test env) → caught by catch block + $response = $controller->syncPayment(1, $this->createStub(FactureService::class), 'sk_live_fake_key_for_test'); + + $this->assertSame(302, $response->getStatusCode()); + } + + // --------------------------------------------------------------- + // cancel + // --------------------------------------------------------------- + + public function testCancelThrows404WhenAdvertNotFound(): void + { + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn(null); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $this->expectException(NotFoundHttpException::class); + $controller->cancel(999); + } + + public function testCancelAddsFlashWhenAlreadyCancelled(): void + { + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(5); + + $advert = $this->buildAdvert(); + $advert->setState(Advert::STATE_CANCEL); + $advert->setCustomer($customer); + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + + $controller = $this->buildController($em); + + $response = $controller->cancel(1); + + $this->assertSame(302, $response->getStatusCode()); + } + + public function testCancelSuccessfullyWithDevis(): void + { + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(5); + + $devis = $this->createMock(Devis::class); + $devis->expects($this->once())->method('setAdvert')->with(null); + + $advert = $this->buildAdvert('AP/2026-030'); + $advert->setState(Advert::STATE_SEND); + $advert->setCustomer($customer); + $advert->setDevis($devis); + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $meilisearch = $this->createMock(MeilisearchService::class); + $meilisearch->expects($this->once())->method('indexAdvert')->with($advert); + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + $em->expects($this->once())->method('flush'); + + $controller = $this->buildController($em, $meilisearch); + + $response = $controller->cancel(1); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame(Advert::STATE_CANCEL, $advert->getState()); + $this->assertNull($advert->getDevis()); + } + + public function testCancelSuccessfullyWithoutDevis(): void + { + $customer = $this->createStub(Customer::class); + $customer->method('getId')->willReturn(6); + + $advert = $this->buildAdvert('AP/2026-031'); + $advert->setState(Advert::STATE_CREATED); + $advert->setCustomer($customer); + // devis = null + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $meilisearch = $this->createStub(MeilisearchService::class); + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + $em->expects($this->once())->method('flush'); + + $controller = $this->buildController($em, $meilisearch); + + $response = $controller->cancel(1); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame(Advert::STATE_CANCEL, $advert->getState()); + } + + public function testCancelWithNullCustomerRedirectsToIndex(): void + { + $advert = $this->buildAdvert('AP/2026-032'); + $advert->setState(Advert::STATE_CREATED); + // customer = null + + $advertRepo = $this->createStub(EntityRepository::class); + $advertRepo->method('find')->willReturn($advert); + + $meilisearch = $this->createStub(MeilisearchService::class); + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getRepository')->willReturn($advertRepo); + $em->expects($this->once())->method('flush'); + + $controller = $this->buildController($em, $meilisearch); + + $response = $controller->cancel(1); + + $this->assertSame(302, $response->getStatusCode()); + } +} diff --git a/tests/Controller/Admin/ClientsControllerTest.php b/tests/Controller/Admin/ClientsControllerTest.php index ea1b305..499075c 100644 --- a/tests/Controller/Admin/ClientsControllerTest.php +++ b/tests/Controller/Admin/ClientsControllerTest.php @@ -1285,4 +1285,45 @@ class ClientsControllerTest extends TestCase ); $this->assertSame(302, $response->getStatusCode()); } + + public function testShowPostContactsCreateWithEmptyNames(): void + { + // Covers the persistNewContact early-return branch when firstName or lastName is empty + $customer = $this->buildCustomer(); + + $entityRepo = $this->createStub(\Doctrine\ORM\EntityRepository::class); + $entityRepo->method('findBy')->willReturn([]); + $entityRepo->method('count')->willReturn(0); + $entityRepo->method('findOneBy')->willReturn(null); + $entityRepo->method('find')->willReturn(null); + + $em = $this->createMock(\Doctrine\ORM\EntityManagerInterface::class); + $em->method('getRepository')->willReturn($entityRepo); + // flush should NOT be called because persistNewContact returns early + $em->expects($this->never())->method('flush'); + + $request = new Request(['tab' => 'contacts'], [ + 'contact_action' => 'create', + 'contact_firstName' => '', // empty -> early return + 'contact_lastName' => 'Dupont', + ]); + $request->setMethod('POST'); + $request->setSession(new Session(new MockArraySessionStorage())); + + $controller = $this->createController($request); + + $response = $controller->show( + $customer, + $request, + $em, + $this->createStub(\App\Service\OvhService::class), + $this->createStub(\App\Service\CloudflareService::class), + $this->createStub(\App\Service\DnsCheckService::class), + $this->createStub(\App\Service\EsyMailDnsService::class), + $this->createStub(\Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface::class), + $this->createStub(MailerService::class), + $this->createStub(Environment::class), + ); + $this->assertSame(302, $response->getStatusCode()); + } } diff --git a/tests/Controller/Admin/ComptabiliteControllerTest.php b/tests/Controller/Admin/ComptabiliteControllerTest.php index 4d06392..6258f58 100644 --- a/tests/Controller/Admin/ComptabiliteControllerTest.php +++ b/tests/Controller/Admin/ComptabiliteControllerTest.php @@ -1430,4 +1430,39 @@ class ComptabiliteControllerTest extends TestCase $response = $controller->rapportFinancier($request); $this->assertSame(200, $response->getStatusCode()); } + + /** + * signCallback — covers the sendSignedDocumentEmail branches where metadata keys + * 'period_from' and 'period_to' are absent, so $periodFrom and $periodTo fall back to ''. + */ + public function testSignCallbackWithSessionAndPdfNoMetadataPeriods(): void + { + $tmpPdf = tempnam(sys_get_temp_dir(), 'compta_nometa_').'.pdf'; + file_put_contents($tmpPdf, '%PDF-1.4 pdf'); + + $controller = $this->buildSignController(); + + $request = new Request(); + $session = new Session(new MockArraySessionStorage()); + $session->set('compta_submitter_id', 111); + $request->setSession($session); + + $docuSeal = $this->createStub(\App\Service\DocuSealService::class); + $docuSeal->method('getSubmitterData')->willReturn([ + 'documents' => [['url' => 'file://'.$tmpPdf]], + 'audit_log_url' => null, + // metadata has NO period_from / period_to keys → covers the '' fallback branches + 'metadata' => [], + ]); + + $mailer = $this->createStub(\App\Service\MailerService::class); + $twig = $this->createStub(\Twig\Environment::class); + $twig->method('render')->willReturn(''); + + $response = $controller->signCallback('journal-ventes', $request, $docuSeal, $mailer, $twig); + + @unlink($tmpPdf); + + $this->assertSame(302, $response->getStatusCode()); + } } diff --git a/tests/Service/ActionServiceTest.php b/tests/Service/ActionServiceTest.php new file mode 100644 index 0000000..a0c39f2 --- /dev/null +++ b/tests/Service/ActionServiceTest.php @@ -0,0 +1,720 @@ +createMock(Customer::class); + $customer->method('getId')->willReturn(1); + $customer->method('getEmail')->willReturn('client@example.com'); + $customer->method('getFullName')->willReturn('Acme SARL'); + $customer->method('getState')->willReturn($state); + + return $customer; + } + + private function makeWebsite(string $state = Website::STATE_OPEN): Website&MockObject + { + $website = $this->createMock(Website::class); + $website->method('getId')->willReturn(10); + $website->method('getName')->willReturn('Mon Site'); + $website->method('getUuid')->willReturn('uuid-1234'); + $website->method('getState')->willReturn($state); + + return $website; + } + + private function makeDomainEmail(string $state = 'active'): DomainEmail&MockObject + { + $email = $this->createMock(DomainEmail::class); + $email->method('getId')->willReturn(20); + $email->method('getFullEmail')->willReturn('contact@example.com'); + $email->method('getState')->willReturn($state); + + return $email; + } + + /** Returns a repo stub that always responds to findBy with the given results. */ + private function makeRepo(array $results = []): EntityRepository&MockObject + { + $repo = $this->createMock(EntityRepository::class); + $repo->method('findBy')->willReturn($results); + + return $repo; + } + + /** + * Build an EM mock whose getRepository() returns different repos per class. + * + * @param array $repoMap class => repo + */ + private function makeEmWithRepos(array $repoMap): EntityManagerInterface&MockObject + { + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getRepository')->willReturnCallback( + static function (string $class) use ($repoMap) { + return $repoMap[$class] ?? (new class extends EntityRepository { + public function __construct() {} + public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array { return []; } + }); + } + ); + + return $em; + } + + // ─── setUp ───────────────────────────────────────────────────────────────── + + protected function setUp(): void + { + $this->em = $this->createMock(EntityManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->service = new ActionService($this->em, $this->logger); + } + + // ══════════════════════════════════════════════════════════════════════════ + // suspendCustomer + // ══════════════════════════════════════════════════════════════════════════ + + public function testSuspendCustomerSuccess(): void + { + $customer = $this->makeCustomer(Customer::STATE_ACTIVE); + $customer->expects($this->once())->method('setState')->with(Customer::STATE_SUSPENDED); + + $website = $this->makeWebsite(Website::STATE_OPEN); + $website->expects($this->once())->method('setState')->with(Website::STATE_SUSPENDED); + + $user = $this->createMock(User::class); + $domain = new Domain($this->createStub(Customer::class), 'example.com'); + + $domainEmail = $this->makeDomainEmail('active'); + $domainEmail->expects($this->once())->method('setState')->with('suspended'); + + $websiteRepo = $this->makeRepo([$website]); + $domainRepo = $this->makeRepo([$domain]); + $domainEmailRepo = $this->makeRepo([$domainEmail]); + + $em = $this->makeEmWithRepos([ + Website::class => $websiteRepo, + Domain::class => $domainRepo, + DomainEmail::class => $domainEmailRepo, + ]); + $em->expects($this->atLeastOnce())->method('persist')->with($this->isInstanceOf(ActionLog::class)); + $em->expects($this->atLeastOnce())->method('flush'); + + $service = new ActionService($em, $this->logger); + $result = $service->suspendCustomer($customer, 'Impaye'); + + $this->assertTrue($result); + } + + public function testSuspendCustomerAlreadySuspended(): void + { + $customer = $this->makeCustomer(Customer::STATE_SUSPENDED); + $customer->expects($this->never())->method('setState'); + + $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ActionLog::class)); + $this->em->expects($this->once())->method('flush'); + + $result = $this->service->suspendCustomer($customer); + + $this->assertTrue($result); + } + + public function testSuspendCustomerException(): void + { + $customer = $this->makeCustomer(Customer::STATE_ACTIVE); + $customer->method('setState')->willThrowException(new \RuntimeException('DB error')); + + // EM must be able to persist/flush the two ActionLogs (initial log + error log) + $this->em->expects($this->exactly(2))->method('persist')->with($this->isInstanceOf(ActionLog::class)); + $this->em->expects($this->exactly(2))->method('flush'); + + $result = $this->service->suspendCustomer($customer); + + $this->assertFalse($result); + } + + public function testSuspendCustomerWithNoDomains(): void + { + $customer = $this->makeCustomer(Customer::STATE_ACTIVE); + $customer->expects($this->once())->method('setState')->with(Customer::STATE_SUSPENDED); + + $websiteRepo = $this->makeRepo([]); + $domainRepo = $this->makeRepo([]); + + $em = $this->makeEmWithRepos([ + Website::class => $websiteRepo, + Domain::class => $domainRepo, + ]); + $em->method('persist'); + $em->method('flush'); + + $service = new ActionService($em, $this->logger); + $result = $service->suspendCustomer($customer, 'test'); + + $this->assertTrue($result); + } + + // ══════════════════════════════════════════════════════════════════════════ + // unsuspendCustomer + // ══════════════════════════════════════════════════════════════════════════ + + public function testUnsuspendCustomerSuccess(): void + { + $customer = $this->makeCustomer(Customer::STATE_SUSPENDED); + $customer->expects($this->once())->method('setState')->with(Customer::STATE_ACTIVE); + + $website = $this->makeWebsite(Website::STATE_SUSPENDED); + $website->expects($this->once())->method('setState')->with(Website::STATE_OPEN); + + $domain = new Domain($this->createStub(Customer::class), 'example.com'); + $domainEmail = $this->makeDomainEmail('suspended'); + $domainEmail->expects($this->once())->method('setState')->with('active'); + + $websiteRepo = $this->makeRepo([$website]); + $domainRepo = $this->makeRepo([$domain]); + $domainEmailRepo = $this->makeRepo([$domainEmail]); + + $em = $this->makeEmWithRepos([ + Website::class => $websiteRepo, + Domain::class => $domainRepo, + DomainEmail::class => $domainEmailRepo, + ]); + $em->expects($this->atLeastOnce())->method('persist')->with($this->isInstanceOf(ActionLog::class)); + $em->expects($this->atLeastOnce())->method('flush'); + + $service = new ActionService($em, $this->logger); + $result = $service->unsuspendCustomer($customer, 'Paiement recu'); + + $this->assertTrue($result); + } + + public function testUnsuspendCustomerNotSuspended(): void + { + $customer = $this->makeCustomer(Customer::STATE_ACTIVE); + $customer->expects($this->never())->method('setState'); + + $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ActionLog::class)); + $this->em->expects($this->once())->method('flush'); + + $result = $this->service->unsuspendCustomer($customer); + + $this->assertTrue($result); + } + + public function testUnsuspendCustomerException(): void + { + $customer = $this->makeCustomer(Customer::STATE_SUSPENDED); + $customer->method('setState')->willThrowException(new \RuntimeException('DB error')); + + // initial log + error log + $this->em->expects($this->exactly(2))->method('persist')->with($this->isInstanceOf(ActionLog::class)); + $this->em->expects($this->exactly(2))->method('flush'); + + $result = $this->service->unsuspendCustomer($customer); + + $this->assertFalse($result); + } + + public function testUnsuspendCustomerWithNoSites(): void + { + $customer = $this->makeCustomer(Customer::STATE_SUSPENDED); + $customer->expects($this->once())->method('setState')->with(Customer::STATE_ACTIVE); + + $websiteRepo = $this->makeRepo([]); + $domainRepo = $this->makeRepo([]); + + $em = $this->makeEmWithRepos([ + Website::class => $websiteRepo, + Domain::class => $domainRepo, + ]); + $em->method('persist'); + $em->method('flush'); + + $service = new ActionService($em, $this->logger); + $result = $service->unsuspendCustomer($customer); + + $this->assertTrue($result); + } + + // ══════════════════════════════════════════════════════════════════════════ + // suspendWebsite + // ══════════════════════════════════════════════════════════════════════════ + + public function testSuspendWebsiteSuccess(): void + { + $customer = $this->makeCustomer(); + $website = $this->makeWebsite(Website::STATE_OPEN); + $website->expects($this->once())->method('setState')->with(Website::STATE_SUSPENDED); + + // flush() is called once in the public method + once inside log() + $this->em->expects($this->atLeastOnce())->method('flush'); + $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ActionLog::class)); + + $this->service->suspendWebsite($website, $customer, 'Impaye'); + } + + public function testSuspendWebsiteAlreadySuspended(): void + { + $customer = $this->makeCustomer(); + $website = $this->makeWebsite(Website::STATE_SUSPENDED); + $website->expects($this->never())->method('setState'); + + $this->em->expects($this->never())->method('flush'); + $this->em->expects($this->never())->method('persist'); + + $this->service->suspendWebsite($website, $customer); + } + + // ══════════════════════════════════════════════════════════════════════════ + // unsuspendWebsite + // ══════════════════════════════════════════════════════════════════════════ + + public function testUnsuspendWebsiteSuccess(): void + { + $customer = $this->makeCustomer(); + $website = $this->makeWebsite(Website::STATE_SUSPENDED); + $website->expects($this->once())->method('setState')->with(Website::STATE_OPEN); + + $this->em->expects($this->atLeastOnce())->method('flush'); + $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ActionLog::class)); + + $this->service->unsuspendWebsite($website, $customer, 'Paiement recu'); + } + + public function testUnsuspendWebsiteFromOpenState(): void + { + // unsuspendWebsite does NOT check current state — it always sets to OPEN + $customer = $this->makeCustomer(); + $website = $this->makeWebsite(Website::STATE_OPEN); + $website->expects($this->once())->method('setState')->with(Website::STATE_OPEN); + + $this->em->expects($this->atLeastOnce())->method('flush'); + $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ActionLog::class)); + + $this->service->unsuspendWebsite($website, $customer); + } + + // ══════════════════════════════════════════════════════════════════════════ + // suspendDomainEmail + // ══════════════════════════════════════════════════════════════════════════ + + public function testSuspendDomainEmailSuccess(): void + { + $customer = $this->makeCustomer(); + $domainEmail = $this->makeDomainEmail('active'); + $domainEmail->expects($this->once())->method('setState')->with('suspended'); + + $this->em->expects($this->atLeastOnce())->method('flush'); + $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ActionLog::class)); + + $this->service->suspendDomainEmail($domainEmail, $customer, 'Impaye'); + } + + public function testSuspendDomainEmailAlreadySuspended(): void + { + $customer = $this->makeCustomer(); + $domainEmail = $this->makeDomainEmail('suspended'); + $domainEmail->expects($this->never())->method('setState'); + + $this->em->expects($this->never())->method('flush'); + $this->em->expects($this->never())->method('persist'); + + $this->service->suspendDomainEmail($domainEmail, $customer); + } + + // ══════════════════════════════════════════════════════════════════════════ + // unsuspendDomainEmail + // ══════════════════════════════════════════════════════════════════════════ + + public function testUnsuspendDomainEmailSuccess(): void + { + $customer = $this->makeCustomer(); + $domainEmail = $this->makeDomainEmail('suspended'); + $domainEmail->expects($this->once())->method('setState')->with('active'); + + $this->em->expects($this->atLeastOnce())->method('flush'); + $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ActionLog::class)); + + $this->service->unsuspendDomainEmail($domainEmail, $customer, 'Paiement recu'); + } + + public function testUnsuspendDomainEmailAlwaysSetsActive(): void + { + // Like unsuspendWebsite, this always sets state — no guard + $customer = $this->makeCustomer(); + $domainEmail = $this->makeDomainEmail('active'); + $domainEmail->expects($this->once())->method('setState')->with('active'); + + $this->em->expects($this->atLeastOnce())->method('flush'); + $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ActionLog::class)); + + $this->service->unsuspendDomainEmail($domainEmail, $customer); + } + + // ══════════════════════════════════════════════════════════════════════════ + // disableCustomer + // ══════════════════════════════════════════════════════════════════════════ + + public function testDisableCustomerSuccess(): void + { + $customer = $this->makeCustomer(Customer::STATE_SUSPENDED); + $customer->expects($this->once())->method('setState')->with(Customer::STATE_DISABLED); + + // log() + flush inside log, then flush inside try block + $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ActionLog::class)); + $this->em->expects($this->atLeastOnce())->method('flush'); + + $result = $this->service->disableCustomer($customer, 'Resiliation'); + + $this->assertTrue($result); + } + + public function testDisableCustomerException(): void + { + $customer = $this->makeCustomer(Customer::STATE_SUSPENDED); + $customer->method('setState')->willThrowException(new \RuntimeException('DB error')); + + // initial log + error log + $this->em->expects($this->exactly(2))->method('persist')->with($this->isInstanceOf(ActionLog::class)); + $this->em->expects($this->exactly(2))->method('flush'); + + $result = $this->service->disableCustomer($customer); + + $this->assertFalse($result); + } + + public function testDisableCustomerDefaultReason(): void + { + $customer = $this->makeCustomer(Customer::STATE_ACTIVE); + $customer->expects($this->once())->method('setState')->with(Customer::STATE_DISABLED); + + $this->em->method('persist'); + $this->em->method('flush'); + + $result = $this->service->disableCustomer($customer); + + $this->assertTrue($result); + } + + // ══════════════════════════════════════════════════════════════════════════ + // markForDeletion + // ══════════════════════════════════════════════════════════════════════════ + + public function testMarkForDeletionSuccess(): void + { + $customer = $this->makeCustomer(Customer::STATE_DISABLED); + $customer->expects($this->once())->method('setState')->with(Customer::STATE_PENDING_DELETE); + + $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ActionLog::class)); + $this->em->expects($this->atLeastOnce())->method('flush'); + + // The method also calls $this->logger->critical() directly (outside log()) + $this->logger->expects($this->atLeastOnce())->method('critical'); + + $result = $this->service->markForDeletion($customer, 'Resiliation contrat'); + + $this->assertTrue($result); + } + + public function testMarkForDeletionException(): void + { + $customer = $this->makeCustomer(Customer::STATE_DISABLED); + $customer->method('setState')->willThrowException(new \RuntimeException('DB error')); + + // initial log + error log + $this->em->expects($this->exactly(2))->method('persist')->with($this->isInstanceOf(ActionLog::class)); + $this->em->expects($this->exactly(2))->method('flush'); + + $result = $this->service->markForDeletion($customer); + + $this->assertFalse($result); + } + + public function testMarkForDeletionDefaultReason(): void + { + $customer = $this->makeCustomer(Customer::STATE_ACTIVE); + $customer->expects($this->once())->method('setState')->with(Customer::STATE_PENDING_DELETE); + + $this->em->method('persist'); + $this->em->method('flush'); + $this->logger->method('critical'); + + $result = $this->service->markForDeletion($customer); + + $this->assertTrue($result); + } + + // ══════════════════════════════════════════════════════════════════════════ + // log() private — severity branches (exercised via public methods) + // ══════════════════════════════════════════════════════════════════════════ + + /** + * suspendCustomer uses 'critical' severity → logger::critical() + * This also tests the 'warning' branch via the "already suspended" path. + */ + public function testLogSeverityCriticalBranch(): void + { + // The initial log in suspendCustomer for a non-suspended customer uses 'critical' + $customer = $this->makeCustomer(Customer::STATE_ACTIVE); + $customer->method('setState'); + + $this->em->method('persist'); + $this->em->method('flush'); + $this->em->method('getRepository')->willReturn($this->makeRepo([])); + + $this->logger->expects($this->atLeastOnce())->method('critical'); + + $this->service->suspendCustomer($customer, 'Test critical log'); + } + + public function testLogSeverityWarningBranch(): void + { + // suspendCustomer with already-suspended customer triggers 'warning' log + $customer = $this->makeCustomer(Customer::STATE_SUSPENDED); + + $this->em->method('persist'); + $this->em->method('flush'); + + $this->logger->expects($this->once())->method('warning'); + + $this->service->suspendCustomer($customer); + } + + public function testLogSeverityInfoBranch(): void + { + // unsuspendCustomer uses 'info' severity + $customer = $this->makeCustomer(Customer::STATE_SUSPENDED); + $customer->method('setState'); + + $this->em->method('persist'); + $this->em->method('flush'); + $this->em->method('getRepository')->willReturn($this->makeRepo([])); + + $this->logger->expects($this->atLeastOnce())->method('info'); + + $this->service->unsuspendCustomer($customer); + } + + public function testLogSeverityWarningBranchForUnsuspend(): void + { + // unsuspendCustomer with non-suspended customer triggers 'warning' log + $customer = $this->makeCustomer(Customer::STATE_ACTIVE); + + $this->em->method('persist'); + $this->em->method('flush'); + + $this->logger->expects($this->once())->method('warning'); + + $this->service->unsuspendCustomer($customer); + } + + /** + * The private log() method has a 'danger' severity branch that maps to logger::error(). + * We exercise it via reflection to ensure full method coverage. + */ + public function testLogSeverityDangerBranchCallsLoggerError(): void + { + $customer = $this->makeCustomer(Customer::STATE_ACTIVE); + + $this->em->method('persist'); + $this->em->method('flush'); + + // The 'danger' severity maps to $this->logger->error(...) + $this->logger->expects($this->once())->method('error'); + + $method = new \ReflectionMethod(ActionService::class, 'log'); + $method->setAccessible(true); + $method->invoke( + $this->service, + ActionLog::ACTION_SUSPEND_CUSTOMER, + $customer, + 'Test danger severity', + 'danger', + true, + [], + ); + } + + // ══════════════════════════════════════════════════════════════════════════ + // logError() private — exercised via exception paths above + dedicated test + // ══════════════════════════════════════════════════════════════════════════ + + public function testLogErrorPersistsActionLogWithErrorMessage(): void + { + $customer = $this->makeCustomer(Customer::STATE_ACTIVE); + $customer->method('setState')->willThrowException(new \InvalidArgumentException('Invalid state')); + + $persistedLogs = []; + $this->em->method('persist')->willReturnCallback( + static function (object $obj) use (&$persistedLogs): void { + $persistedLogs[] = $obj; + } + ); + $this->em->method('flush'); + + $this->service->disableCustomer($customer); + + // There should be 2 ActionLog entries: the initial info log, then the error log + $this->assertCount(2, $persistedLogs); + + /** @var ActionLog $errorLog */ + $errorLog = $persistedLogs[1]; + $this->assertInstanceOf(ActionLog::class, $errorLog); + $this->assertFalse($errorLog->isSuccess()); + $this->assertNotNull($errorLog->getErrorMessage()); + $this->assertStringContainsString('Invalid state', $errorLog->getErrorMessage()); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Edge cases — log() with entityId branch (via suspendWebsite/suspendDomainEmail) + // ══════════════════════════════════════════════════════════════════════════ + + public function testSuspendWebsiteSetsEntityIdInActionLog(): void + { + $customer = $this->makeCustomer(); + $website = $this->makeWebsite(Website::STATE_OPEN); + $website->method('setState'); + + $persistedLogs = []; + $this->em->method('persist')->willReturnCallback( + static function (object $obj) use (&$persistedLogs): void { + $persistedLogs[] = $obj; + } + ); + $this->em->method('flush'); + + $this->service->suspendWebsite($website, $customer); + + $this->assertCount(1, $persistedLogs); + /** @var ActionLog $log */ + $log = $persistedLogs[0]; + $this->assertSame(ActionLog::ACTION_SUSPEND_WEBSITE, $log->getAction()); + $this->assertSame(10, $log->getEntityId()); + $this->assertSame('Website', $log->getEntityType()); + } + + public function testSuspendDomainEmailSetsEntityIdInActionLog(): void + { + $customer = $this->makeCustomer(); + $domainEmail = $this->makeDomainEmail('active'); + $domainEmail->method('setState'); + + $persistedLogs = []; + $this->em->method('persist')->willReturnCallback( + static function (object $obj) use (&$persistedLogs): void { + $persistedLogs[] = $obj; + } + ); + $this->em->method('flush'); + + $this->service->suspendDomainEmail($domainEmail, $customer); + + $this->assertCount(1, $persistedLogs); + /** @var ActionLog $log */ + $log = $persistedLogs[0]; + $this->assertSame(ActionLog::ACTION_SUSPEND_DOMAIN_EMAIL, $log->getAction()); + $this->assertSame(20, $log->getEntityId()); + $this->assertSame('DomainEmail', $log->getEntityType()); + } + + // ══════════════════════════════════════════════════════════════════════════ + // suspendCustomer — multiple websites and domain emails + // ══════════════════════════════════════════════════════════════════════════ + + public function testSuspendCustomerSuspendsMultipleWebsitesAndEmails(): void + { + $customer = $this->makeCustomer(Customer::STATE_ACTIVE); + $customer->method('setState'); + + $website1 = $this->makeWebsite(Website::STATE_OPEN); + $website1->expects($this->once())->method('setState')->with(Website::STATE_SUSPENDED); + $website2 = $this->makeWebsite(Website::STATE_OPEN); + $website2->expects($this->once())->method('setState')->with(Website::STATE_SUSPENDED); + + $domain = new Domain($this->createStub(Customer::class), 'example.com'); + $domainEmail1 = $this->makeDomainEmail('active'); + $domainEmail1->expects($this->once())->method('setState')->with('suspended'); + $domainEmail2 = $this->makeDomainEmail('active'); + $domainEmail2->expects($this->once())->method('setState')->with('suspended'); + + $websiteRepo = $this->makeRepo([$website1, $website2]); + $domainRepo = $this->makeRepo([$domain]); + $domainEmailRepo = $this->makeRepo([$domainEmail1, $domainEmail2]); + + $em = $this->makeEmWithRepos([ + Website::class => $websiteRepo, + Domain::class => $domainRepo, + DomainEmail::class => $domainEmailRepo, + ]); + $em->method('persist'); + $em->method('flush'); + + $service = new ActionService($em, $this->logger); + $result = $service->suspendCustomer($customer, 'Test multi'); + + $this->assertTrue($result); + } + + // ══════════════════════════════════════════════════════════════════════════ + // unsuspendCustomer — multiple websites and domain emails + // ══════════════════════════════════════════════════════════════════════════ + + public function testUnsuspendCustomerUnsuspendsMultipleWebsitesAndEmails(): void + { + $customer = $this->makeCustomer(Customer::STATE_SUSPENDED); + $customer->method('setState'); + + $website1 = $this->makeWebsite(Website::STATE_SUSPENDED); + $website1->expects($this->once())->method('setState')->with(Website::STATE_OPEN); + $website2 = $this->makeWebsite(Website::STATE_SUSPENDED); + $website2->expects($this->once())->method('setState')->with(Website::STATE_OPEN); + + $domain = new Domain($this->createStub(Customer::class), 'example.com'); + $domainEmail1 = $this->makeDomainEmail('suspended'); + $domainEmail1->expects($this->once())->method('setState')->with('active'); + $domainEmail2 = $this->makeDomainEmail('suspended'); + $domainEmail2->expects($this->once())->method('setState')->with('active'); + + $websiteRepo = $this->makeRepo([$website1, $website2]); + $domainRepo = $this->makeRepo([$domain]); + $domainEmailRepo = $this->makeRepo([$domainEmail1, $domainEmail2]); + + $em = $this->makeEmWithRepos([ + Website::class => $websiteRepo, + Domain::class => $domainRepo, + DomainEmail::class => $domainEmailRepo, + ]); + $em->method('persist'); + $em->method('flush'); + + $service = new ActionService($em, $this->logger); + $result = $service->unsuspendCustomer($customer, 'Paiement recu'); + + $this->assertTrue($result); + } +} diff --git a/tests/Service/DocuSealServiceTest.php b/tests/Service/DocuSealServiceTest.php index aa4be4f..5317d74 100644 --- a/tests/Service/DocuSealServiceTest.php +++ b/tests/Service/DocuSealServiceTest.php @@ -614,6 +614,29 @@ class DocuSealServiceTest extends TestCase $this->assertFalse($result); } + public function testDownloadSignedDevisWithAuditUrlNotFound(): void + { + // Covers the downloadAuditForDevis branch where file_get_contents returns false + // (audit URL does not exist → early return null) + $orderNumber = new \App\Entity\OrderNumber('04/2026-00001'); + $devis = new Devis($orderNumber, 'secret'); + $devis->setSubmissionId('42'); + + $fakePdf = $this->projectDir.'/signed-noaudit.pdf'; + file_put_contents($fakePdf, '%PDF-signed'); + + $this->api->method('getSubmitter')->willReturn([ + 'documents' => [['url' => $fakePdf]], + // Provide an audit URL that does NOT resolve to valid content + 'audit_log_url' => '/nonexistent/path/audit.pdf', + ]); + + $result = $this->service->downloadSignedDevis($devis); + + // PDF was downloaded successfully; audit simply not attached + $this->assertTrue($result); + } + // --- sendComptaForSignature --- public function testSendComptaForSignatureSuccess(): void diff --git a/tests/Service/Pdf/AdvertPdfTest.php b/tests/Service/Pdf/AdvertPdfTest.php new file mode 100644 index 0000000..e4df226 --- /dev/null +++ b/tests/Service/Pdf/AdvertPdfTest.php @@ -0,0 +1,183 @@ +projectDir = sys_get_temp_dir().'/advert-pdf-test-'.bin2hex(random_bytes(4)); + mkdir($this->projectDir.'/public', 0775, true); + + $this->kernel = $this->createStub(KernelInterface::class); + $this->kernel->method('getProjectDir')->willReturn($this->projectDir); + } + + protected function tearDown(): void + { + $this->removeDir($this->projectDir); + } + + private function removeDir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) as $item) { + if ('.' === $item || '..' === $item) { + continue; + } + $path = $dir.'/'.$item; + is_dir($path) ? $this->removeDir($path) : unlink($path); + } + rmdir($dir); + } + + private function makeAdvert(bool $withCustomer = true, bool $withTva = false): Advert + { + $orderNumber = new OrderNumber('04/2026-00001'); + $advert = new Advert($orderNumber, 'secret'); + $advert->setTotalHt('100.00'); + $advert->setTotalTva($withTva ? '20.00' : '0.00'); + $advert->setTotalTtc($withTva ? '120.00' : '100.00'); + + if ($withCustomer) { + $user = new \App\Entity\User(); + $user->setEmail('client@test.fr'); + $user->setFirstName('Jean'); + $user->setLastName('Dupont'); + $user->setPassword('h'); + $customer = new Customer($user); + $customer->setRaisonSociale('ACME SARL'); + $customer->setAddress('1 rue de la Paix'); + $customer->setAddress2('Bat A'); + $customer->setZipCode('75001'); + $customer->setCity('Paris'); + $advert->setCustomer($customer); + } + + return $advert; + } + + // ─── __construct — without urlGenerator (qrBase64 stays empty) ─────────── + + public function testConstructWithoutUrlGeneratorAndNoLines(): void + { + $advert = $this->makeAdvert(); + $pdf = new AdvertPdf($this->kernel, $advert); + + // The object was constructed without throwing + $this->assertInstanceOf(AdvertPdf::class, $pdf); + } + + public function testConstructWithLines(): void + { + $advert = $this->makeAdvert(); + $line1 = new AdvertLine($advert, 'Service A', '50.00', 1); + $line1->setDescription('Description A'); + $advert->addLine($line1); + + $line2 = new AdvertLine($advert, 'Service B', '50.00', 2); + // No description (empty string branch) + $advert->addLine($line2); + + $pdf = new AdvertPdf($this->kernel, $advert); + $this->assertInstanceOf(AdvertPdf::class, $pdf); + } + + // ─── generate — without urlGenerator, no items (covers basic path) ─────── + + public function testGenerateWithNoItemsAndNoTva(): void + { + $advert = $this->makeAdvert(); + $pdf = new AdvertPdf($this->kernel, $advert); + $pdf->generate(); + + $output = $pdf->Output('S'); + $this->assertStringStartsWith('%PDF', $output); + } + + public function testGenerateWithItemsAndTva(): void + { + $advert = $this->makeAdvert(true, true); // with TVA + $line = new AdvertLine($advert, 'Prestation web', '100.00', 1); + $line->setDescription('Realisation site vitrine'); + $advert->addLine($line); + + $pdf = new AdvertPdf($this->kernel, $advert); + $pdf->generate(); + + $output = $pdf->Output('S'); + $this->assertStringStartsWith('%PDF', $output); + } + + public function testGenerateWithManyItemsTriggersNewPage(): void + { + // Add enough items to force an extra page (GetY() + 30 > 220) + $advert = $this->makeAdvert(); + for ($i = 1; $i <= 20; ++$i) { + $line = new AdvertLine($advert, 'Item '.$i, '5.00', $i); + $line->setDescription(str_repeat('Long description text. ', 5)); + $advert->addLine($line); + } + + $pdf = new AdvertPdf($this->kernel, $advert); + $pdf->generate(); + + $output = $pdf->Output('S'); + $this->assertStringStartsWith('%PDF', $output); + } + + public function testGenerateWithLogoFile(): void + { + // Create a small real JPEG (1x1 pixel) so FPDF Image() succeeds + $logoPath = $this->projectDir.'/public/logo.jpg'; + // Minimal valid JPEG bytes (1x1 white pixel) + $jpegData = base64_decode('/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AJQAB/9k='); + file_put_contents($logoPath, $jpegData); + + $advert = $this->makeAdvert(); + $pdf = new AdvertPdf($this->kernel, $advert); + $pdf->generate(); + + $output = $pdf->Output('S'); + $this->assertStringStartsWith('%PDF', $output); + } + + public function testGenerateWithNoCustomer(): void + { + $advert = $this->makeAdvert(false); // no customer + $pdf = new AdvertPdf($this->kernel, $advert); + $pdf->generate(); + + $output = $pdf->Output('S'); + $this->assertStringStartsWith('%PDF', $output); + } + + public function testConstructWithUrlGenerator(): void + { + $advert = $this->makeAdvert(); + + $urlGenerator = $this->createStub(\Symfony\Component\Routing\Generator\UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturn('https://example.com/pay/04-2026-00001'); + + $pdf = new AdvertPdf($this->kernel, $advert, $urlGenerator); + $this->assertInstanceOf(AdvertPdf::class, $pdf); + + // generate() with qrBase64 set → covers displayQrCode (ignored) but also the generate() path + $pdf->generate(); + $output = $pdf->Output('S'); + $this->assertStringStartsWith('%PDF', $output); + } +}