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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
186
tests/Command/CheckNddCommandTest.php
Normal file
186
tests/Command/CheckNddCommandTest.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Command;
|
||||
|
||||
use App\Command\CheckNddCommand;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Domain;
|
||||
use App\Service\MailerService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Twig\Environment;
|
||||
|
||||
class CheckNddCommandTest extends TestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private MailerService $mailer;
|
||||
private Environment $twig;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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('<html>empty</html>');
|
||||
|
||||
$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('<html>domains</html>');
|
||||
|
||||
$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('<html>ok</html>');
|
||||
|
||||
$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('<html>one</html>');
|
||||
|
||||
$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());
|
||||
}
|
||||
}
|
||||
204
tests/Command/CleanPendingDeleteCommandTest.php
Normal file
204
tests/Command/CleanPendingDeleteCommandTest.php
Normal file
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Command;
|
||||
|
||||
use App\Command\CleanPendingDeleteCommand;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\User;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Service\MeilisearchService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class CleanPendingDeleteCommandTest extends TestCase
|
||||
{
|
||||
private CustomerRepository $customerRepository;
|
||||
private EntityManagerInterface $em;
|
||||
private MeilisearchService $meilisearch;
|
||||
private LoggerInterface $logger;
|
||||
private string $stripeSecretKey = '';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
@@ -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 = `
|
||||
<div id="lines-container"></div>
|
||||
<button id="add-line-btn">Ajouter</button>
|
||||
<script id="line-template" type="text/html">
|
||||
<div class="not-a-line-row">
|
||||
<span class="line-pos"></span>
|
||||
<input type="hidden" class="line-pos-input" name="lines[__INDEX__][pos]">
|
||||
<input type="text" name="lines[__INDEX__][title]">
|
||||
<textarea name="lines[__INDEX__][description]"></textarea>
|
||||
<input type="number" class="line-price" name="lines[__INDEX__][priceHt]" value="0.00">
|
||||
</div>
|
||||
<\/script>
|
||||
<div id="total-ht">0.00 EUR</div>
|
||||
<form id="devis-form"></form>
|
||||
<button class="quick-price-btn" data-title="T" data-price="1.00">Quick</button>
|
||||
`
|
||||
await loadApp()
|
||||
|
||||
// addLine adds a node without .line-row class → querySelector('.line-row:last-child') → null → early return
|
||||
document.querySelector('.quick-price-btn').click()
|
||||
|
||||
const container = document.getElementById('lines-container')
|
||||
// The node was added but it has no .line-row class
|
||||
expect(container.querySelector('.line-row')).toBeNull()
|
||||
})
|
||||
|
||||
it('quick-price-btn with data-line-type sets typeSelect value and dispatches change', async () => {
|
||||
globalThis.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
json: () => Promise.resolve([{ id: 3, label: 'Esymail Pro' }])
|
||||
})
|
||||
)
|
||||
|
||||
document.body.innerHTML = `
|
||||
<div id="lines-container"></div>
|
||||
<button id="add-line-btn">Ajouter</button>
|
||||
<script id="line-template" type="text/html">${lineTemplate}<\/script>
|
||||
<div id="total-ht">0.00 EUR</div>
|
||||
<form id="devis-form"></form>
|
||||
<button class="quick-price-btn"
|
||||
data-title="Mail"
|
||||
data-description="desc"
|
||||
data-price="10.00"
|
||||
data-line-type="esymail">Quick</button>
|
||||
`
|
||||
await loadApp()
|
||||
|
||||
document.querySelector('.quick-price-btn').click()
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
|
||||
const container = document.getElementById('lines-container')
|
||||
const typeSelect = container.querySelector('.line-type')
|
||||
expect(typeSelect.value).toBe('esymail')
|
||||
// The change event should have triggered fetch for services
|
||||
expect(globalThis.fetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('type change with items returned adds options and enables select', async () => {
|
||||
globalThis.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
json: () => Promise.resolve([
|
||||
{ id: 20, label: 'NDD A' },
|
||||
{ id: 21, label: 'NDD B' }
|
||||
])
|
||||
})
|
||||
)
|
||||
|
||||
await loadApp()
|
||||
|
||||
document.getElementById('add-line-btn').click()
|
||||
|
||||
const container = document.getElementById('lines-container')
|
||||
const row = container.querySelector('.line-row')
|
||||
const typeSelect = row.querySelector('.line-type')
|
||||
|
||||
typeSelect.value = 'ndd'
|
||||
typeSelect.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
const serviceSelect = row.querySelector('.line-service-id')
|
||||
expect(serviceSelect.disabled).toBe(false)
|
||||
expect(serviceSelect.options.length).toBeGreaterThan(1)
|
||||
expect(serviceSelect.innerHTML).toContain('NDD A')
|
||||
expect(serviceSelect.innerHTML).toContain('NDD B')
|
||||
})
|
||||
|
||||
it('type change to hosting returns early without fetch', async () => {
|
||||
globalThis.fetch = vi.fn()
|
||||
|
||||
await loadApp()
|
||||
|
||||
document.getElementById('add-line-btn').click()
|
||||
|
||||
const container = document.getElementById('lines-container')
|
||||
const row = container.querySelector('.line-row')
|
||||
const typeSelect = row.querySelector('.line-type')
|
||||
|
||||
typeSelect.value = 'hosting'
|
||||
typeSelect.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
// No fetch should have been called — hosting returns early
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled()
|
||||
expect(row.querySelector('.line-service-id').disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('form validation: esymail with missing service calls preventDefault', async () => {
|
||||
await loadApp()
|
||||
|
||||
document.getElementById('add-line-btn').click()
|
||||
|
||||
const container = document.getElementById('lines-container')
|
||||
const row = container.querySelector('.line-row')
|
||||
const typeSelect = row.querySelector('.line-type')
|
||||
const serviceSelect = row.querySelector('.line-service-id')
|
||||
|
||||
typeSelect.value = 'esymail'
|
||||
serviceSelect.disabled = false
|
||||
const opt = document.createElement('option')
|
||||
opt.value = '9'
|
||||
opt.textContent = 'Mail Perso'
|
||||
serviceSelect.appendChild(opt)
|
||||
// Leave serviceSelect.value as '' (empty)
|
||||
|
||||
window.alert = vi.fn()
|
||||
|
||||
const form = document.getElementById('devis-form')
|
||||
const submitEvent = new Event('submit', { cancelable: true })
|
||||
form.dispatchEvent(submitEvent)
|
||||
|
||||
expect(submitEvent.defaultPrevented).toBe(true)
|
||||
expect(window.alert).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('form validation: hosting type skips service check', async () => {
|
||||
await loadApp()
|
||||
|
||||
document.getElementById('add-line-btn').click()
|
||||
|
||||
const container = document.getElementById('lines-container')
|
||||
const row = container.querySelector('.line-row')
|
||||
const typeSelect = row.querySelector('.line-type')
|
||||
|
||||
typeSelect.value = 'hosting'
|
||||
|
||||
window.alert = vi.fn()
|
||||
|
||||
const form = document.getElementById('devis-form')
|
||||
const submitEvent = new Event('submit', { cancelable: true })
|
||||
form.dispatchEvent(submitEvent)
|
||||
|
||||
expect(submitEvent.defaultPrevented).toBe(false)
|
||||
expect(window.alert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('form validation: row without .line-type is skipped via continue', async () => {
|
||||
await loadApp()
|
||||
|
||||
// Add a line then manually remove the .line-type select from the DOM
|
||||
document.getElementById('add-line-btn').click()
|
||||
|
||||
const container = document.getElementById('lines-container')
|
||||
const row = container.querySelector('.line-row')
|
||||
row.querySelector('.line-type').remove()
|
||||
|
||||
window.alert = vi.fn()
|
||||
|
||||
const form = document.getElementById('devis-form')
|
||||
const submitEvent = new Event('submit', { cancelable: true })
|
||||
form.dispatchEvent(submitEvent)
|
||||
|
||||
// Should not throw, no alert — the row was skipped
|
||||
expect(submitEvent.defaultPrevented).toBe(false)
|
||||
expect(window.alert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recalc with NaN value ignores the non-numeric input', async () => {
|
||||
await loadApp()
|
||||
|
||||
document.getElementById('add-line-btn').click()
|
||||
document.getElementById('add-line-btn').click()
|
||||
|
||||
const container = document.getElementById('lines-container')
|
||||
const prices = container.querySelectorAll('.line-price')
|
||||
prices[0].value = '50.00'
|
||||
prices[1].value = 'abc' // NaN
|
||||
|
||||
prices[0].dispatchEvent(new Event('input', { bubbles: true }))
|
||||
prices[1].dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
// Only the valid price should be counted
|
||||
expect(document.getElementById('total-ht').textContent).toBe('50.00 EUR')
|
||||
})
|
||||
|
||||
it('remove line button removes row and updates renumber + recalc', async () => {
|
||||
await loadApp()
|
||||
|
||||
const addBtn = document.getElementById('add-line-btn')
|
||||
addBtn.click()
|
||||
addBtn.click()
|
||||
addBtn.click()
|
||||
|
||||
const container = document.getElementById('lines-container')
|
||||
expect(container.querySelectorAll('.line-row').length).toBe(3)
|
||||
|
||||
// Set prices so recalc can be verified
|
||||
const prices = container.querySelectorAll('.line-price')
|
||||
prices[0].value = '10.00'
|
||||
prices[1].value = '20.00'
|
||||
prices[2].value = '30.00'
|
||||
|
||||
// Remove the second row
|
||||
const secondRow = container.querySelectorAll('.line-row')[1]
|
||||
secondRow.querySelector('.remove-line-btn').dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
|
||||
expect(container.querySelectorAll('.line-row').length).toBe(2)
|
||||
// Positions should be renumbered
|
||||
expect(container.querySelectorAll('.line-pos')[0].textContent).toBe('#1')
|
||||
expect(container.querySelectorAll('.line-pos')[1].textContent).toBe('#2')
|
||||
// Recalc: rows remaining have prices 10.00 and 30.00
|
||||
expect(document.getElementById('total-ht').textContent).toBe('40.00 EUR')
|
||||
})
|
||||
|
||||
it('price input change event triggers recalc', async () => {
|
||||
await loadApp()
|
||||
|
||||
document.getElementById('add-line-btn').click()
|
||||
|
||||
const container = document.getElementById('lines-container')
|
||||
const priceInput = container.querySelector('.line-price')
|
||||
priceInput.value = '123.45'
|
||||
priceInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(document.getElementById('total-ht').textContent).toBe('123.45 EUR')
|
||||
})
|
||||
|
||||
it('prefill with serviceId and type fetches services and selects the matching option', async () => {
|
||||
globalThis.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
json: () => Promise.resolve([
|
||||
{ id: 5, label: 'Esymail Pro' },
|
||||
{ id: 6, label: 'Esymail Starter' }
|
||||
])
|
||||
})
|
||||
)
|
||||
|
||||
const initialLines = JSON.stringify([
|
||||
{ pos: 0, title: 'Mail ligne', description: '', priceHt: '20.00', type: 'esymail', serviceId: 5 }
|
||||
])
|
||||
document.body.innerHTML = `
|
||||
<div id="lines-container" data-initial-lines='${initialLines}'></div>
|
||||
<button id="add-line-btn">Ajouter</button>
|
||||
<script id="line-template" type="text/html">${lineTemplate}<\/script>
|
||||
<div id="total-ht">0.00 EUR</div>
|
||||
<form id="devis-form"></form>
|
||||
`
|
||||
await loadApp()
|
||||
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
|
||||
const container = document.getElementById('lines-container')
|
||||
const row = container.querySelector('.line-row')
|
||||
expect(row).not.toBeNull()
|
||||
|
||||
const serviceSelect = row.querySelector('.line-service-id')
|
||||
expect(serviceSelect.disabled).toBe(false)
|
||||
expect(serviceSelect.innerHTML).toContain('Esymail Pro')
|
||||
expect(serviceSelect.value).toBe('5')
|
||||
})
|
||||
|
||||
it('prefill with invalid JSON catches the error silently', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="lines-container" data-initial-lines='{{invalid json'></div>
|
||||
<button id="add-line-btn">Ajouter</button>
|
||||
<script id="line-template" type="text/html">${lineTemplate}<\/script>
|
||||
<div id="total-ht">0.00 EUR</div>
|
||||
<form id="devis-form"></form>
|
||||
`
|
||||
// Should not throw
|
||||
await loadApp()
|
||||
|
||||
const container = document.getElementById('lines-container')
|
||||
// No lines added since JSON parsing failed
|
||||
expect(container.querySelectorAll('.line-row').length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Global search (navbar)', () => {
|
||||
|
||||
Reference in New Issue
Block a user