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:
Serreau Jovann
2026-04-08 16:16:39 +02:00
parent 0eec8536e2
commit 54720d62f5
4 changed files with 797 additions and 2 deletions

View 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());
}
}

View 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());
}
}

View File

@@ -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)', () => {