Add LibreTranslate auto-translation, improve test coverage, fix code duplication

Translation system:
- Add LibreTranslate container (dev + prod), CPU-only, no port exposed, FR/EN/ES/DE/IT
- Create app:translate command: reads *.fr.yaml, translates incrementally, preserves placeholders
- Makefile: make trans / make trans_prod (stops container after translation)
- Ansible: start libretranslate -> translate -> stop during deploy
- Prod container restart: "no" (only runs during deploy)
- .gitignore: ignore generated *.en/es/de/it.yaml files
- 11 tests for TranslateCommand (API unreachable, empty, incremental, obsolete keys, placeholders, fallback)

Test coverage improvements:
- OrderController: event ended (400), invalid cart JSON, invalid email, stock zero (4 new tests)
- AccountController: finance stats all statuses (paid/pending/refunded/cancelled), soldCounts (2 new tests)
- JS cart: checkout without error elements, hide error on retry, stock polling edge cases (singular, no label, qty zero, unknown billet) (8 new tests)
- JS editor: comment node sanitization (1 new test)
- JS tabs: missing panel, generated id, parent null, click no-panel (5 new tests)

Code duplication fixes:
- MeilisearchConsistencyCommand: extract diffAndReport() method (was duplicated 3x)
- Email templates: extract _order_items_table.html.twig partial (shared by notification + cancelled)
- SonarQube: exclude src/Entity/** from CPD (getters/setters duplication)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-23 11:44:13 +01:00
parent 04927ec988
commit 42d06dd49f
16 changed files with 1087 additions and 138 deletions

View File

@@ -1954,6 +1954,94 @@ class AccountControllerTest extends WebTestCase
return $category;
}
public function testOrganizerFinanceStatsWithAllStatuses(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER'], true);
$user->setStripeAccountId('acct_finance_'.uniqid());
$user->setStripeChargesEnabled(true);
$user->setCommissionRate(3.0);
$em->flush();
$event = $this->createEvent($em, $user);
$event->setIsOnline(true);
$category = $this->createCategory($em, $event);
$billet = $this->createBillet($em, $category);
$em->flush();
$statuses = [
\App\Entity\BilletBuyer::STATUS_PAID,
\App\Entity\BilletBuyer::STATUS_PENDING,
\App\Entity\BilletBuyer::STATUS_REFUNDED,
\App\Entity\BilletBuyer::STATUS_CANCELLED,
];
foreach ($statuses as $i => $status) {
$order = new \App\Entity\BilletBuyer();
$order->setEvent($event);
$order->setFirstName('F'.$i);
$order->setLastName('L'.$i);
$order->setEmail('f'.$i.'@test.fr');
$order->setOrderNumber('2026-'.random_int(10000, 99999));
$order->setTotalHT(1000);
$order->setStatus($status);
if (\App\Entity\BilletBuyer::STATUS_PAID === $status) {
$order->setPaidAt(new \DateTimeImmutable());
}
$item = new \App\Entity\BilletBuyerItem();
$item->setBillet($billet);
$item->setBilletName('Entree');
$item->setQuantity(1);
$item->setUnitPriceHT(1000);
$order->addItem($item);
$em->persist($order);
}
$em->flush();
$client->loginUser($user);
$client->request('GET', '/mon-compte?tab=payouts');
self::assertResponseIsSuccessful();
}
public function testEditEventStatsTabWithSoldCounts(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER'], true);
$event = $this->createEvent($em, $user);
$event->setIsOnline(true);
$category = $this->createCategory($em, $event);
$billet = $this->createBillet($em, $category);
$order = new \App\Entity\BilletBuyer();
$order->setEvent($event);
$order->setFirstName('Jean');
$order->setLastName('Sold');
$order->setEmail('sold@test.fr');
$order->setOrderNumber('2026-'.random_int(10000, 99999));
$order->setTotalHT(1000);
$order->setStatus(\App\Entity\BilletBuyer::STATUS_PAID);
$order->setPaidAt(new \DateTimeImmutable());
$item = new \App\Entity\BilletBuyerItem();
$item->setBillet($billet);
$item->setBilletName('Entree');
$item->setQuantity(3);
$item->setUnitPriceHT(1000);
$order->addItem($item);
$em->persist($order);
$em->flush();
$client->loginUser($user);
$client->request('GET', '/mon-compte/evenement/'.$event->getId().'/modifier?tab=stats');
self::assertResponseIsSuccessful();
}
/**
* @param list<string> $roles
*/

View File

@@ -530,4 +530,103 @@ class OrderControllerTest extends WebTestCase
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('redirect', $data);
}
public function testCreateOrderEventEnded(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createOrga($em);
$event = new Event();
$event->setAccount($user);
$event->setTitle('Past Event');
$event->setStartAt(new \DateTimeImmutable('-2 days'));
$event->setEndAt(new \DateTimeImmutable('-1 day'));
$event->setAddress('1 rue');
$event->setZipcode('75001');
$event->setCity('Paris');
$event->setIsOnline(true);
$em->persist($event);
$em->flush();
$client->request('POST', '/evenement/'.$event->getId().'/commander', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
['billetId' => 1, 'qty' => 1],
]));
self::assertResponseStatusCodeSame(400);
$data = json_decode($client->getResponse()->getContent(), true);
self::assertStringContainsString('termine', $data['error']);
}
public function testCreateOrderInvalidCartStructure(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createOrga($em);
[$event] = $this->createEventWithBillet($em, $user);
$client->request('POST', '/evenement/'.$event->getId().'/commander', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
['invalid' => 'data'],
]));
self::assertResponseStatusCodeSame(400);
}
public function testGuestSubmitInvalidEmail(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createOrga($em);
[$event, $billet] = $this->createEventWithBillet($em, $user);
$order = $this->createOrder($em, $event, $billet);
$client->request('POST', '/commande/'.$order->getId().'/informations', [
'first_name' => 'Jean',
'last_name' => 'Dupont',
'email' => 'not-an-email',
]);
self::assertResponseRedirects('/commande/'.$order->getId().'/informations');
}
public function testCreateOrderStockZeroSkipsBillet(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createOrga($em);
$event = new Event();
$event->setAccount($user);
$event->setTitle('Stock Zero Event');
$event->setStartAt(new \DateTimeImmutable('2026-08-01 10:00'));
$event->setEndAt(new \DateTimeImmutable('2026-08-01 18:00'));
$event->setAddress('1 rue');
$event->setZipcode('75001');
$event->setCity('Paris');
$event->setIsOnline(true);
$em->persist($event);
$category = new Category();
$category->setName('Cat');
$category->setEvent($event);
$category->setStartAt(new \DateTimeImmutable('-1 day'));
$category->setEndAt(new \DateTimeImmutable('+30 days'));
$em->persist($category);
$billet = new Billet();
$billet->setName('Sold Out');
$billet->setCategory($category);
$billet->setPriceHT(1500);
$billet->setQuantity(0);
$em->persist($billet);
$em->flush();
$client->request('POST', '/evenement/'.$event->getId().'/commander', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
['billetId' => $billet->getId(), 'qty' => 1],
]));
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('redirect', $data);
}
}