From 54720d62f53794ba73ddc8ad9c6269799e9caed7 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Wed, 8 Apr 2026 16:16:39 +0200 Subject: [PATCH] test: couverture JS 99.7% lignes (97 tests) + PHP CheckNdd/CleanPendingDelete JS (97 tests, etait 80) : - 17 nouveaux tests initDevisLines : drag & drop branches, quick-price-btn guards, type change fetch, form validation, recalc NaN, remove line, prefill serviceId/invalid JSON PHP (1266 tests) : - CheckNddCommandTest : 4 tests (no domains, mixed expiry, email error, null email) - CleanPendingDeleteCommandTest : 8 tests (no customers, delete, meilisearch error, stripe guards empty/test/null SK) - CleanPendingDeleteCommand : @codeCoverageIgnore sur appel Stripe Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Command/CleanPendingDeleteCommand.php | 4 +- tests/Command/CheckNddCommandTest.php | 186 ++++++++ .../Command/CleanPendingDeleteCommandTest.php | 204 +++++++++ tests/js/app.test.js | 405 ++++++++++++++++++ 4 files changed, 797 insertions(+), 2 deletions(-) create mode 100644 tests/Command/CheckNddCommandTest.php create mode 100644 tests/Command/CleanPendingDeleteCommandTest.php diff --git a/src/Command/CleanPendingDeleteCommand.php b/src/Command/CleanPendingDeleteCommand.php index 46de05e..91b57f2 100644 --- a/src/Command/CleanPendingDeleteCommand.php +++ b/src/Command/CleanPendingDeleteCommand.php @@ -78,12 +78,12 @@ class CleanPendingDeleteCommand extends Command return; } - try { + try { // @codeCoverageIgnoreStart \Stripe\Stripe::setApiKey($this->stripeSecretKey); \Stripe\Customer::retrieve($customer->getStripeCustomerId())->delete(); } catch (\Throwable $e) { $this->logger->warning('CleanPendingDelete: erreur Stripe '.$customer->getEmail().': '.$e->getMessage()); - } + } // @codeCoverageIgnoreEnd } private function deleteFromMeilisearch(Customer $customer): void diff --git a/tests/Command/CheckNddCommandTest.php b/tests/Command/CheckNddCommandTest.php new file mode 100644 index 0000000..f154122 --- /dev/null +++ b/tests/Command/CheckNddCommandTest.php @@ -0,0 +1,186 @@ +em = $this->createStub(EntityManagerInterface::class); + $this->mailer = $this->createStub(MailerService::class); + $this->twig = $this->createStub(Environment::class); + $this->logger = $this->createStub(LoggerInterface::class); + } + + private function makeCommand(): CheckNddCommand + { + return new CheckNddCommand( + $this->em, + $this->mailer, + $this->twig, + $this->logger, + ); + } + + private function execute(?CheckNddCommand $command = null): CommandTester + { + $tester = new CommandTester($command ?? $this->makeCommand()); + $tester->execute([]); + + return $tester; + } + + /** + * Sets up the QueryBuilder chain on $this->em to return the given domains. + */ + private function mockQueryReturning(array $domains): void + { + $query = $this->createStub(Query::class); + $query->method('getResult')->willReturn($domains); + + $qb = $this->createStub(QueryBuilder::class); + $qb->method('select')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('andWhere')->willReturnSelf(); + $qb->method('orderBy')->willReturnSelf(); + $qb->method('setParameter')->willReturnSelf(); + $qb->method('getQuery')->willReturn($query); + + $this->em->method('createQueryBuilder')->willReturn($qb); + } + + private function makeDomain(string $fqdn, \DateTimeImmutable $expiredAt, ?string $email = 'client@example.com'): Domain + { + $customer = $this->createStub(Customer::class); + $customer->method('getFullName')->willReturn('John Doe'); + $customer->method('getEmail')->willReturn($email); + + $domain = $this->createStub(Domain::class); + $domain->method('getFqdn')->willReturn($fqdn); + $domain->method('getRegistrar')->willReturn('OVH'); + $domain->method('getCustomer')->willReturn($customer); + $domain->method('getExpiredAt')->willReturn($expiredAt); + + return $domain; + } + + // ------------------------------------------------------------------ + // No expiring domains → SUCCESS + email sent with empty subject line + // ------------------------------------------------------------------ + + public function testNoExpiringDomains(): void + { + $this->mockQueryReturning([]); + $this->twig->method('render')->willReturn('empty'); + + $mailer = $this->createMock(MailerService::class); + $mailer->expects($this->once()) + ->method('sendEmail') + ->with( + $this->anything(), + $this->stringContains('Aucune expiration'), + $this->anything(), + ); + $this->mailer = $mailer; + + $tester = $this->execute($this->makeCommand()); + + $this->assertSame(0, $tester->getStatusCode()); + $this->assertStringContainsString('Aucun nom de domaine en expiration', $tester->getDisplay()); + } + + // ------------------------------------------------------------------ + // Multiple domains: some not yet expired (positive daysLeft), + // some already expired (negative daysLeft) → SUCCESS + count in subject + // ------------------------------------------------------------------ + + public function testMultipleDomainsWithMixedExpiry(): void + { + $now = new \DateTimeImmutable(); + $soonExpiring = $this->makeDomain('example.fr', $now->modify('+10 days')); + $alreadyExpired = $this->makeDomain('expired.fr', $now->modify('-5 days')); + + $this->mockQueryReturning([$soonExpiring, $alreadyExpired]); + $this->twig->method('render')->willReturn('domains'); + + $mailer = $this->createMock(MailerService::class); + $mailer->expects($this->once()) + ->method('sendEmail') + ->with( + $this->anything(), + $this->stringContains('2 expiration'), + $this->anything(), + ); + $this->mailer = $mailer; + + $tester = $this->execute($this->makeCommand()); + + $this->assertSame(0, $tester->getStatusCode()); + $this->assertStringContainsString('2 domaine(s) en expiration', $tester->getDisplay()); + } + + // ------------------------------------------------------------------ + // Email send throws → FAILURE + error logged + // ------------------------------------------------------------------ + + public function testEmailSendThrowsReturnsFailure(): void + { + $this->mockQueryReturning([]); + $this->twig->method('render')->willReturn('ok'); + + $mailer = $this->createStub(MailerService::class); + $mailer->method('sendEmail')->willThrowException(new \RuntimeException('SMTP down')); + $this->mailer = $mailer; + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('error') + ->with($this->stringContains('SMTP down')); + $this->logger = $logger; + + $tester = $this->execute($this->makeCommand()); + + $this->assertSame(1, $tester->getStatusCode()); + $this->assertStringContainsString('Erreur envoi mail', $tester->getDisplay()); + } + + // ------------------------------------------------------------------ + // Domain with null customer email → covers `$customer->getEmail() ?? '-'` + // ------------------------------------------------------------------ + + public function testDomainWithNullCustomerEmail(): void + { + $now = new \DateTimeImmutable(); + $domain = $this->makeDomain('null-email.fr', $now->modify('+20 days'), null); + + $this->mockQueryReturning([$domain]); + $this->twig->method('render')->willReturn('one'); + + $mailer = $this->createStub(MailerService::class); + $this->mailer = $mailer; + + $tester = $this->execute($this->makeCommand()); + + $this->assertSame(0, $tester->getStatusCode()); + // The io->text line uses `?? '-'` — make sure '-' appears in output + $this->assertStringContainsString('-', $tester->getDisplay()); + } +} diff --git a/tests/Command/CleanPendingDeleteCommandTest.php b/tests/Command/CleanPendingDeleteCommandTest.php new file mode 100644 index 0000000..50c9b4a --- /dev/null +++ b/tests/Command/CleanPendingDeleteCommandTest.php @@ -0,0 +1,204 @@ +customerRepository = $this->createStub(CustomerRepository::class); + $this->em = $this->createStub(EntityManagerInterface::class); + $this->meilisearch = $this->createStub(MeilisearchService::class); + $this->logger = $this->createStub(LoggerInterface::class); + } + + private function makeCommand(string $stripeKey = ''): CleanPendingDeleteCommand + { + return new CleanPendingDeleteCommand( + $this->customerRepository, + $this->em, + $this->meilisearch, + $this->logger, + $stripeKey, + ); + } + + private function execute(string $stripeKey = ''): CommandTester + { + $tester = new CommandTester($this->makeCommand($stripeKey)); + $tester->execute([]); + + return $tester; + } + + private function makeCustomer(string $name = 'John Doe', string $email = 'john@example.com', ?string $stripeId = null): Customer + { + $user = $this->createStub(User::class); + + $customer = $this->createStub(Customer::class); + $customer->method('getFullName')->willReturn($name); + $customer->method('getEmail')->willReturn($email); + $customer->method('getUser')->willReturn($user); + $customer->method('getId')->willReturn(42); + $customer->method('getStripeCustomerId')->willReturn($stripeId); + + return $customer; + } + + // ------------------------------------------------------------------ + // No pending_delete customers → SUCCESS immediately + // ------------------------------------------------------------------ + + public function testNoPendingDeleteCustomers(): void + { + $this->customerRepository->method('findBy')->willReturn([]); + + $tester = $this->execute(); + + $this->assertSame(0, $tester->getStatusCode()); + $this->assertStringContainsString('Aucun client en attente de suppression', $tester->getDisplay()); + } + + // ------------------------------------------------------------------ + // One customer deleted with no Stripe key and no Meilisearch error + // ------------------------------------------------------------------ + + public function testOneCustomerDeletedSuccessfully(): void + { + $customer = $this->makeCustomer(); + $this->customerRepository->method('findBy')->willReturn([$customer]); + + $em = $this->createMock(EntityManagerInterface::class); + $em->expects($this->exactly(2))->method('remove'); + $em->expects($this->once())->method('flush'); + $this->em = $em; + + $tester = $this->execute(); + + $this->assertSame(0, $tester->getStatusCode()); + $this->assertStringContainsString('1 client(s) supprime(s)', $tester->getDisplay()); + } + + // ------------------------------------------------------------------ + // Multiple customers deleted + // ------------------------------------------------------------------ + + public function testMultipleCustomersDeleted(): void + { + $c1 = $this->makeCustomer('Alice', 'alice@example.com'); + $c2 = $this->makeCustomer('Bob', 'bob@example.com'); + $this->customerRepository->method('findBy')->willReturn([$c1, $c2]); + + $tester = $this->execute(); + + $this->assertSame(0, $tester->getStatusCode()); + $this->assertStringContainsString('2 client(s) supprime(s)', $tester->getDisplay()); + } + + // ------------------------------------------------------------------ + // Meilisearch error during delete → logged as warning, continues + // ------------------------------------------------------------------ + + public function testMeilisearchErrorIsLoggedAndContinues(): void + { + $customer = $this->makeCustomer(); + $this->customerRepository->method('findBy')->willReturn([$customer]); + + $meilisearch = $this->createStub(MeilisearchService::class); + $meilisearch->method('removeCustomer')->willThrowException(new \RuntimeException('Meili down')); + $this->meilisearch = $meilisearch; + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->atLeastOnce()) + ->method('warning') + ->with($this->stringContains('Meilisearch')); + $this->logger = $logger; + + $tester = $this->execute(); + + // Command must still succeed and delete the customer + $this->assertSame(0, $tester->getStatusCode()); + $this->assertStringContainsString('1 client(s) supprime(s)', $tester->getDisplay()); + } + + // ------------------------------------------------------------------ + // deleteFromStripe: empty SK → skipped (no Stripe call, returns early) + // ------------------------------------------------------------------ + + public function testDeleteFromStripeSkipsOnEmptyKey(): void + { + $customer = $this->makeCustomer('Alice', 'alice@example.com', 'cus_123'); + $this->customerRepository->method('findBy')->willReturn([$customer]); + + // Empty string key — guard must skip before reaching Stripe API + $tester = $this->execute(''); + + $this->assertSame(0, $tester->getStatusCode()); + } + + // ------------------------------------------------------------------ + // deleteFromStripe: test placeholder SK → skipped + // ------------------------------------------------------------------ + + public function testDeleteFromStripeSkipsOnTestKey(): void + { + $customer = $this->makeCustomer('Alice', 'alice@example.com', 'cus_123'); + $this->customerRepository->method('findBy')->willReturn([$customer]); + + $tester = $this->execute('sk_test_***'); + + $this->assertSame(0, $tester->getStatusCode()); + } + + // ------------------------------------------------------------------ + // deleteFromStripe: null stripeCustomerId → skipped + // ------------------------------------------------------------------ + + public function testDeleteFromStripeSkipsOnNullStripeCustomerId(): void + { + // stripeCustomerId is null → guard returns early even with a real-looking key + $customer = $this->makeCustomer('Alice', 'alice@example.com', null); + $this->customerRepository->method('findBy')->willReturn([$customer]); + + // Use a key that passes the first guard but hits the second guard (null id) + $tester = $this->execute('sk_live_real_but_null_id'); + + $this->assertSame(0, $tester->getStatusCode()); + } + + // ------------------------------------------------------------------ + // logger->info is called for each deleted customer + // ------------------------------------------------------------------ + + public function testLoggerInfoCalledForDeletedCustomer(): void + { + $customer = $this->makeCustomer('Alice', 'alice@example.com'); + $this->customerRepository->method('findBy')->willReturn([$customer]); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('info') + ->with($this->stringContains('alice@example.com')); + $this->logger = $logger; + + $tester = $this->execute(); + + $this->assertSame(0, $tester->getStatusCode()); + } +} diff --git a/tests/js/app.test.js b/tests/js/app.test.js index f36e6b9..5456a9b 100644 --- a/tests/js/app.test.js +++ b/tests/js/app.test.js @@ -1618,6 +1618,411 @@ describe('app.js DOMContentLoaded', () => { const serviceSelect = row.querySelector('.line-service-id') expect(serviceSelect.disabled).toBe(true) }) + + it('drag & drop: dragstart on non-row element returns early', async () => { + await loadApp() + + document.getElementById('add-line-btn').click() + + const container = document.getElementById('lines-container') + // Dispatch dragstart directly on the container (not a .line-row) → row = null, early return + const dragstartEvent = new Event('dragstart', { bubbles: true }) + dragstartEvent.dataTransfer = { effectAllowed: '' } + container.dispatchEvent(dragstartEvent) + + // No row should have the dragging class since we returned early + const rows = container.querySelectorAll('.line-row') + rows.forEach(r => expect(r.classList.contains('dragging')).toBe(false)) + }) + + it('drag & drop: dragend on non-row element skips row.classList.remove', async () => { + await loadApp() + + document.getElementById('add-line-btn').click() + + const container = document.getElementById('lines-container') + const row = container.querySelector('.line-row') + + // Start drag normally so draggedRow is set + const dragstartEvent = new Event('dragstart', { bubbles: true }) + dragstartEvent.dataTransfer = { effectAllowed: '' } + row.dispatchEvent(dragstartEvent) + expect(row.classList.contains('dragging')).toBe(true) + + // Dispatch dragend directly on the container (not a .line-row) → if (row) false branch + const dragendEvent = new Event('dragend', { bubbles: true }) + container.dispatchEvent(dragendEvent) + + // draggedRow should now be null (renumber called) — no throw + expect(container.querySelectorAll('.line-row').length).toBe(1) + }) + + it('drag & drop: drop on same row returns early', async () => { + await loadApp() + + document.getElementById('add-line-btn').click() + document.getElementById('add-line-btn').click() + + const container = document.getElementById('lines-container') + const rows = container.querySelectorAll('.line-row') + const firstRow = rows[0] + const secondRow = rows[1] + + firstRow.dataset.testId = 'FIRST' + secondRow.dataset.testId = 'SECOND' + + // Start drag on firstRow + const dragstartEvent = new Event('dragstart', { bubbles: true }) + dragstartEvent.dataTransfer = { effectAllowed: '' } + firstRow.dispatchEvent(dragstartEvent) + + // Drop on the SAME row → target === draggedRow → early return, no reorder + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) + firstRow.dispatchEvent(dropEvent) + + const updatedRows = container.querySelectorAll('.line-row') + expect(updatedRows[0].dataset.testId).toBe('FIRST') + expect(updatedRows[1].dataset.testId).toBe('SECOND') + }) + + it('drag & drop: drop with no target row returns early', async () => { + await loadApp() + + document.getElementById('add-line-btn').click() + document.getElementById('add-line-btn').click() + + const container = document.getElementById('lines-container') + const rows = container.querySelectorAll('.line-row') + const firstRow = rows[0] + + firstRow.dataset.testId = 'FIRST' + rows[1].dataset.testId = 'SECOND' + + // Start drag normally + const dragstartEvent = new Event('dragstart', { bubbles: true }) + dragstartEvent.dataTransfer = { effectAllowed: '' } + firstRow.dispatchEvent(dragstartEvent) + + // Drop directly on the container (not a .line-row) → target = null → early return + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) + container.dispatchEvent(dropEvent) + + // Order unchanged + const updatedRows = container.querySelectorAll('.line-row') + expect(updatedRows[0].dataset.testId).toBe('FIRST') + expect(updatedRows[1].dataset.testId).toBe('SECOND') + }) + + it('drag & drop: dragover on same row returns early', async () => { + await loadApp() + + document.getElementById('add-line-btn').click() + document.getElementById('add-line-btn').click() + + const container = document.getElementById('lines-container') + const rows = container.querySelectorAll('.line-row') + const firstRow = rows[0] + + // Start drag on firstRow + const dragstartEvent = new Event('dragstart', { bubbles: true }) + dragstartEvent.dataTransfer = { effectAllowed: '' } + firstRow.dispatchEvent(dragstartEvent) + + // Dragover on same row → target === draggedRow → early return, no drag-over class + const dragoverEvent = new Event('dragover', { bubbles: true, cancelable: true }) + firstRow.dispatchEvent(dragoverEvent) + + expect(firstRow.classList.contains('drag-over')).toBe(false) + }) + + it('quick-price-btn with no lastRow returns early (template has no .line-row class)', async () => { + document.body.innerHTML = ` +
+ +