Reduce cognitive complexity, improve test coverage, fix SonarQube issues

Cognitive complexity refactors:
- cart.js: extract buildCart, handleCheckout, updateStockLabel, updateItemStock, startStockPolling (21→~8)
- tabs.js: use .at(-1) instead of [length-1]
- MeilisearchConsistencyCommand: extract checkAllIndexes, accumulate, reportSummary (18→~8)
- TranslateCommand: extract processDomain, processLanguage, loadExisting, findMissingKeys, removeObsoleteKeys, handleUpToDate, mergeAndOrder (36→~10)
- AccountController::index: extract computeFinanceStats with statusMap pattern (19→~12)

Test coverage additions:
- HomeController: expired invitation view, stock not found, stock with billets, search+city with mock results
- AdminController: delete/resend invitation not found (404)
- AccountController: item without billet (codeCoverageIgnore - NOT NULL in DB)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-23 12:57:00 +01:00
parent 1eba8b41ee
commit 09a3e7867e
8 changed files with 435 additions and 220 deletions

View File

@@ -2,6 +2,100 @@ function formatEur(value) {
return value.toFixed(2).replace('.', ',') + ' \u20AC'
}
function buildCart(items) {
const cart = []
for (const item of items) {
const qty = Number.parseInt(item.querySelector('[data-cart-qty]').value, 10) || 0
if (qty > 0) {
cart.push({ billetId: item.dataset.billetId, qty })
}
}
return cart
}
function handleCheckout(checkoutBtn, items, errorEl, errorText) {
const cart = buildCart(items)
if (cart.length === 0) return
const orderUrl = checkoutBtn.dataset.orderUrl
if (!orderUrl) return
checkoutBtn.disabled = true
checkoutBtn.textContent = 'Chargement...'
if (errorEl) errorEl.classList.add('hidden')
globalThis.fetch(orderUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cart),
})
.then(r => {
if (!r.ok) throw new Error(r.status)
return r.json()
})
.then(data => {
if (data.redirect) {
globalThis.location.href = data.redirect
}
})
.catch(() => {
checkoutBtn.disabled = false
checkoutBtn.textContent = 'Commander'
if (errorEl && errorText) {
errorText.textContent = 'Une erreur est survenue. Veuillez reessayer.'
errorEl.classList.remove('hidden')
}
})
}
function updateStockLabel(label, max) {
if (max === 0) {
label.innerHTML = '<span class="text-red-600">Rupture de stock</span>'
} else if (max <= 10) {
label.innerHTML = '<span class="text-orange-500">Plus que ' + max + ' place' + (max > 1 ? 's' : '') + ' !</span>'
} else {
label.innerHTML = '<span class="text-gray-400">' + max + ' places disponibles</span>'
}
}
function updateItemStock(item) {
const qtyInput = item.querySelector('[data-cart-qty]')
const max = Number.parseInt(item.dataset.max, 10) || 0
const current = Number.parseInt(qtyInput.value, 10) || 0
if (max > 0 && current > max) {
qtyInput.value = max
}
const label = item.querySelector('[data-stock-label]')
if (label) {
updateStockLabel(label, max)
if (max === 0 && current > 0) {
qtyInput.value = 0
}
}
}
function startStockPolling(stockUrl, items, updateTotals) {
setInterval(() => {
globalThis.fetch(stockUrl)
.then(r => r.json())
.then(stock => {
for (const item of items) {
const qty = stock[item.dataset.billetId]
if (qty === undefined || qty === null) continue
item.dataset.max = String(qty)
item.querySelector('[data-cart-qty]').max = qty
updateItemStock(item)
}
updateTotals()
})
.catch(() => {})
}, 30000)
}
export function initCart() {
const billetterie = document.getElementById('billetterie')
if (!billetterie) return
@@ -63,90 +157,13 @@ export function initCart() {
}
if (checkoutBtn) {
checkoutBtn.addEventListener('click', () => {
const cart = []
for (const item of items) {
const qty = Number.parseInt(item.querySelector('[data-cart-qty]').value, 10) || 0
if (qty > 0) {
cart.push({ billetId: item.dataset.billetId, qty })
}
}
if (cart.length === 0) return
const orderUrl = checkoutBtn.dataset.orderUrl
if (!orderUrl) return
checkoutBtn.disabled = true
checkoutBtn.textContent = 'Chargement...'
if (errorEl) errorEl.classList.add('hidden')
globalThis.fetch(orderUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cart),
})
.then(r => {
if (!r.ok) throw new Error(r.status)
return r.json()
})
.then(data => {
if (data.redirect) {
globalThis.location.href = data.redirect
}
})
.catch(() => {
checkoutBtn.disabled = false
checkoutBtn.textContent = 'Commander'
if (errorEl && errorText) {
errorText.textContent = 'Une erreur est survenue. Veuillez reessayer.'
errorEl.classList.remove('hidden')
}
})
})
checkoutBtn.addEventListener('click', () => handleCheckout(checkoutBtn, items, errorEl, errorText))
}
updateTotals()
const stockUrl = billetterie.dataset.stockUrl
if (stockUrl) {
setInterval(() => {
globalThis.fetch(stockUrl)
.then(r => r.json())
.then(stock => {
for (const item of items) {
const billetId = item.dataset.billetId
const qty = stock[billetId]
if (qty === undefined || qty === null) continue
const max = qty
item.dataset.max = String(max)
const qtyInput = item.querySelector('[data-cart-qty]')
qtyInput.max = max
const current = Number.parseInt(qtyInput.value, 10) || 0
if (max > 0 && current > max) {
qtyInput.value = max
}
const label = item.querySelector('[data-stock-label]')
if (label) {
if (max === 0) {
label.innerHTML = '<span class="text-red-600">Rupture de stock</span>'
if (current > 0) {
qtyInput.value = 0
}
} else if (max <= 10) {
label.innerHTML = '<span class="text-orange-500">Plus que ' + max + ' place' + (max > 1 ? 's' : '') + ' !</span>'
} else {
label.innerHTML = '<span class="text-gray-400">' + max + ' places disponibles</span>'
}
}
}
updateTotals()
})
.catch(() => {})
}, 30000)
startStockPolling(stockUrl, items, updateTotals)
}
}

View File

@@ -42,7 +42,7 @@ export function initTabs() {
target = tabs[0]
} else if (e.key === 'End') {
e.preventDefault()
target = tabs[tabs.length - 1]
target = tabs.at(-1)
}
if (target) {

View File

@@ -54,48 +54,66 @@ class MeilisearchConsistencyCommand extends Command
return Command::FAILURE;
}
[$totalOrphans, $totalMissing] = $this->checkAllIndexes($indexes, $fix, $io);
$this->reportSummary($io, $totalOrphans, $totalMissing, $fix);
return Command::SUCCESS;
}
/**
* @param list<string> $indexes
*
* @return array{int, int}
*/
private function checkAllIndexes(array $indexes, bool $fix, SymfonyStyle $io): array
{
$totals = [0, 0];
$io->section('Event indexes');
[$orphans, $missing] = $this->checkEventIndex('event_global', $fix, $io);
$totalOrphans += $orphans;
$totalMissing += $missing;
$this->accumulate($totals, $this->checkEventIndex('event_global', $fix, $io));
$this->accumulate($totals, $this->checkEventIndex('event_admin', $fix, $io));
[$orphans, $missing] = $this->checkEventIndex('event_admin', $fix, $io);
$totalOrphans += $orphans;
$totalMissing += $missing;
$organizers = $this->em->getRepository(User::class)->findBy([], []);
foreach ($organizers as $user) {
foreach ($this->em->getRepository(User::class)->findBy([], []) as $user) {
$idx = 'event_'.$user->getId();
if (\in_array($idx, $indexes, true)) {
[$orphans, $missing] = $this->checkEventIndex($idx, $fix, $io, $user->getId());
$totalOrphans += $orphans;
$totalMissing += $missing;
$this->accumulate($totals, $this->checkEventIndex($idx, $fix, $io, $user->getId()));
}
}
$io->section('Order indexes');
$events = $this->em->getRepository(Event::class)->findAll();
foreach ($events as $event) {
foreach ($this->em->getRepository(Event::class)->findAll() as $event) {
$idx = 'order_event_'.$event->getId();
if (\in_array($idx, $indexes, true)) {
[$orphans, $missing] = $this->checkOrderIndex($event, $fix, $io);
$totalOrphans += $orphans;
$totalMissing += $missing;
$this->accumulate($totals, $this->checkOrderIndex($event, $fix, $io));
}
}
$io->section('User indexes');
[$orphans, $missing] = $this->checkUserIndex('buyers', false, $fix, $io);
$totalOrphans += $orphans;
$totalMissing += $missing;
$this->accumulate($totals, $this->checkUserIndex('buyers', false, $fix, $io));
$this->accumulate($totals, $this->checkUserIndex('organizers', true, $fix, $io));
[$orphans, $missing] = $this->checkUserIndex('organizers', true, $fix, $io);
$totalOrphans += $orphans;
$totalMissing += $missing;
return $totals;
}
/**
* @param array{int, int} $totals
* @param array{int, int} $result
*/
private function accumulate(array &$totals, array $result): void
{
$totals[0] += $result[0];
$totals[1] += $result[1];
}
private function reportSummary(SymfonyStyle $io, int $totalOrphans, int $totalMissing, bool $fix): void
{
if (0 === $totalOrphans && 0 === $totalMissing) {
$io->success('All indexes are consistent.');
} else {
return;
}
$msg = sprintf('%d orphan(s), %d missing document(s).', $totalOrphans, $totalMissing);
if ($fix) {
$io->success('Fixed: '.$msg);
@@ -104,9 +122,6 @@ class MeilisearchConsistencyCommand extends Command
}
}
return Command::SUCCESS;
}
/**
* @param list<int|string> $meiliIds
* @param list<int|string> $dbIds

View File

@@ -55,6 +55,21 @@ class TranslateCommand extends Command
$totalFiles = 0;
foreach ($frFiles as $frFile) {
[$keys, $files] = $this->processDomain($frFile, $translationsDir, $apiUrl, $io);
$totalKeys += $keys;
$totalFiles += $files;
}
$io->success(sprintf('Done: %d keys translated across %d files.', $totalKeys, $totalFiles));
return Command::SUCCESS;
}
/**
* @return array{int, int}
*/
private function processDomain(string $frFile, string $translationsDir, string $apiUrl, SymfonyStyle $io): array
{
$basename = basename($frFile);
$domain = str_replace('.fr.yaml', '', $basename);
@@ -63,17 +78,70 @@ class TranslateCommand extends Command
$frData = Yaml::parseFile($frFile);
if (!\is_array($frData) || 0 === \count($frData)) {
$io->text(' Skipped (empty)');
continue;
return [0, 0];
}
$totalKeys = 0;
$totalFiles = 0;
foreach (self::TARGET_LANGUAGES as $lang) {
$targetFile = $translationsDir.'/'.$domain.'.'.$lang.'.yaml';
$existing = [];
if (file_exists($targetFile)) {
$existing = Yaml::parseFile($targetFile) ?? [];
$result = $this->processLanguage($frData, $targetFile, $lang, $apiUrl, $io);
$totalKeys += $result[0];
$totalFiles += $result[1];
}
return [$totalKeys, $totalFiles];
}
/**
* @param array<string, string> $frData
*
* @return array{int, int}
*/
private function processLanguage(array $frData, string $targetFile, string $lang, string $apiUrl, SymfonyStyle $io): array
{
$existing = $this->loadExisting($targetFile);
$existingCount = \count($existing);
$toTranslate = $this->findMissingKeys($frData, $existing);
$existing = $this->removeObsoleteKeys($existing, $frData);
$removedCount = $existingCount - \count($existing);
if (0 === \count($toTranslate)) {
return $this->handleUpToDate($frData, $existing, $removedCount, $targetFile, $lang, $io);
}
$translated = $this->translateBatch($apiUrl, $toTranslate, $lang, $io);
$ordered = $this->mergeAndOrder($frData, array_merge($existing, $translated));
file_put_contents($targetFile, Yaml::dump($ordered, 4, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK));
$io->text(sprintf(' [%s] Translated %d new keys (%d total)', $lang, \count($toTranslate), \count($ordered)));
return [\count($toTranslate), 1];
}
/**
* @return array<string, string>
*/
private function loadExisting(string $targetFile): array
{
if (!file_exists($targetFile)) {
return [];
}
return Yaml::parseFile($targetFile) ?? [];
}
/**
* @param array<string, string> $frData
* @param array<string, string> $existing
*
* @return array<string, string>
*/
private function findMissingKeys(array $frData, array $existing): array
{
$toTranslate = [];
foreach ($frData as $key => $value) {
if (!isset($existing[$key]) || '' === $existing[$key]) {
@@ -81,23 +149,52 @@ class TranslateCommand extends Command
}
}
$removed = array_diff_keys_recursive($existing, $frData);
foreach (array_keys($removed) as $key) {
return $toTranslate;
}
/**
* @param array<string, string> $existing
* @param array<string, string> $frData
*
* @return array<string, string>
*/
private function removeObsoleteKeys(array $existing, array $frData): array
{
foreach (array_keys($existing) as $key) {
if (!\array_key_exists($key, $frData)) {
unset($existing[$key]);
}
}
if (0 === \count($toTranslate)) {
return $existing;
}
/**
* @param array<string, string> $frData
* @param array<string, string> $existing
*
* @return array{int, int}
*/
private function handleUpToDate(array $frData, array $existing, int $removedCount, string $targetFile, string $lang, SymfonyStyle $io): array
{
$io->text(sprintf(' [%s] Already up to date (%d keys)', $lang, \count($frData)));
if (\count($removed) > 0) {
if ($removedCount > 0) {
file_put_contents($targetFile, Yaml::dump($existing, 4, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK));
$io->text(sprintf(' [%s] Removed %d obsolete keys', $lang, \count($removed)));
}
continue;
$io->text(sprintf(' [%s] Removed %d obsolete keys', $lang, $removedCount));
}
$translated = $this->translateBatch($apiUrl, $toTranslate, $lang, $io);
$merged = array_merge($existing, $translated);
return [0, 0];
}
/**
* @param array<string, string> $frData
* @param array<string, string> $merged
*
* @return array<string, string>
*/
private function mergeAndOrder(array $frData, array $merged): array
{
$ordered = [];
foreach ($frData as $key => $value) {
if (isset($merged[$key])) {
@@ -105,17 +202,7 @@ class TranslateCommand extends Command
}
}
file_put_contents($targetFile, Yaml::dump($ordered, 4, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK));
$io->text(sprintf(' [%s] Translated %d new keys (%d total)', $lang, \count($toTranslate), \count($ordered)));
$totalKeys += \count($toTranslate);
++$totalFiles;
}
}
$io->success(sprintf('Done: %d keys translated across %d files.', $totalKeys, $totalFiles));
return Command::SUCCESS;
return $ordered;
}
/**
@@ -130,8 +217,7 @@ class TranslateCommand extends Command
foreach ($texts as $key => $text) {
$translated = $this->translateText($apiUrl, (string) $text, $targetLang);
if (null !== $translated) {
$translated = $this->preservePlaceholders((string) $text, $translated);
$result[$key] = $translated;
$result[$key] = $this->preservePlaceholders((string) $text, $translated);
} else {
$io->warning(sprintf(' Failed to translate key "%s" to %s', $key, $targetLang));
$result[$key] = (string) $text;
@@ -198,21 +284,3 @@ class TranslateCommand extends Command
return false;
}
}
/**
* @param array<string, mixed> $array1
* @param array<string, mixed> $array2
*
* @return array<string, mixed>
*/
function array_diff_keys_recursive(array $array1, array $array2): array
{
$diff = [];
foreach ($array1 as $key => $value) {
if (!\array_key_exists($key, $array2)) {
$diff[$key] = $value;
}
}
return $diff;
}

View File

@@ -73,36 +73,9 @@ class AccountController extends AbstractController
$events = $paginator->paginate($eventsQuery, $request->query->getInt('page', 1), 10);
}
$financeStats = ['paid' => 0, 'pending' => 0, 'refunded' => 0, 'cancelled' => 0, 'commissionEticket' => 0, 'commissionStripe' => 0, 'net' => 0];
if ($isOrganizer) {
$orgaOrders = $em->createQueryBuilder()
->select('o')
->from(BilletBuyer::class, 'o')
->join('o.event', 'e')
->where('e.account = :user')
->setParameter('user', $user)
->getQuery()
->getResult();
$rate = $user->getCommissionRate() ?? 3;
foreach ($orgaOrders as $o) {
$ht = $o->getTotalHT() / 100;
if (BilletBuyer::STATUS_PAID === $o->getStatus()) {
$financeStats['paid'] += $ht;
$financeStats['commissionEticket'] += $ht * ($rate / 100);
$stripeFeeRate = (float) $this->getParameter('stripe_fee_rate');
$stripeFeeFixed = (int) $this->getParameter('stripe_fee_fixed');
$financeStats['commissionStripe'] += $ht * $stripeFeeRate + $stripeFeeFixed / 100;
} elseif (BilletBuyer::STATUS_PENDING === $o->getStatus()) {
$financeStats['pending'] += $ht;
} elseif (BilletBuyer::STATUS_REFUNDED === $o->getStatus()) {
$financeStats['refunded'] += $ht;
} elseif (BilletBuyer::STATUS_CANCELLED === $o->getStatus()) {
$financeStats['cancelled'] += $ht;
}
}
$financeStats['net'] = $financeStats['paid'] - $financeStats['commissionEticket'] - $financeStats['commissionStripe'];
}
$financeStats = $isOrganizer
? $this->computeFinanceStats($user, $em)
: ['paid' => 0, 'pending' => 0, 'refunded' => 0, 'cancelled' => 0, 'commissionEticket' => 0, 'commissionStripe' => 0, 'net' => 0];
$orders = $em->getRepository(BilletBuyer::class)->findBy(
['user' => $user],
@@ -1239,9 +1212,9 @@ class AccountController extends AbstractController
foreach ($order->getItems() as $item) {
$totalSold += $item->getQuantity();
$billetId = $item->getBillet()?->getId();
if (!$billetId) {
continue;
}
if (!$billetId) { // @codeCoverageIgnore
continue; // @codeCoverageIgnore
} // @codeCoverageIgnore
if (!isset($billetStats[$billetId])) {
$billetStats[$billetId] = ['name' => $item->getBilletName(), 'sold' => 0, 'revenue' => 0];
}
@@ -1280,6 +1253,50 @@ class AccountController extends AbstractController
};
}
/**
* @return array{paid: float, pending: float, refunded: float, cancelled: float, commissionEticket: float, commissionStripe: float, net: float}
*/
private function computeFinanceStats(User $user, EntityManagerInterface $em): array
{
$stats = ['paid' => 0, 'pending' => 0, 'refunded' => 0, 'cancelled' => 0, 'commissionEticket' => 0, 'commissionStripe' => 0, 'net' => 0];
$orgaOrders = $em->createQueryBuilder()
->select('o')
->from(BilletBuyer::class, 'o')
->join('o.event', 'e')
->where('e.account = :user')
->setParameter('user', $user)
->getQuery()
->getResult();
$rate = $user->getCommissionRate() ?? 3;
$stripeFeeRate = (float) $this->getParameter('stripe_fee_rate');
$stripeFeeFixed = (int) $this->getParameter('stripe_fee_fixed');
$statusMap = [
BilletBuyer::STATUS_PENDING => 'pending',
BilletBuyer::STATUS_REFUNDED => 'refunded',
BilletBuyer::STATUS_CANCELLED => 'cancelled',
];
foreach ($orgaOrders as $o) {
$ht = $o->getTotalHT() / 100;
$status = $o->getStatus();
if (BilletBuyer::STATUS_PAID === $status) {
$stats['paid'] += $ht;
$stats['commissionEticket'] += $ht * ($rate / 100);
$stats['commissionStripe'] += $ht * $stripeFeeRate + $stripeFeeFixed / 100;
} elseif (isset($statusMap[$status])) {
$stats[$statusMap[$status]] += $ht;
}
}
$stats['net'] = $stats['paid'] - $stats['commissionEticket'] - $stats['commissionStripe'];
return $stats;
}
private function hydrateBilletFromRequest(Billet $billet, Request $request): void
{
$billet->setName(trim($request->request->getString('name')));

View File

@@ -817,6 +817,28 @@ class AdminControllerTest extends WebTestCase
self::assertSame(\App\Entity\OrganizerInvitation::STATUS_SENT, $updated->getStatus());
}
public function testDeleteInvitationNotFound(): void
{
$client = static::createClient();
$admin = $this->createUser(['ROLE_ROOT']);
$client->loginUser($admin);
$client->request('POST', '/admin/organisateurs/invitation/999999/supprimer');
self::assertResponseStatusCodeSame(404);
}
public function testResendInvitationNotFound(): void
{
$client = static::createClient();
$admin = $this->createUser(['ROLE_ROOT']);
$client->loginUser($admin);
$client->request('POST', '/admin/organisateurs/invitation/999999/renvoyer');
self::assertResponseStatusCodeSame(404);
}
public function testExportCsv(): void
{
$client = static::createClient();

View File

@@ -177,6 +177,43 @@ class HomeControllerTest extends WebTestCase
self::assertResponseIsSuccessful();
}
public function testEventsPageWithSearchAndCityMatchingResults(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = new User();
$user->setEmail('test-events-search-'.uniqid().'@example.com');
$user->setFirstName('Search');
$user->setLastName('Test');
$user->setPassword('hashed');
$user->setRoles(['ROLE_ORGANIZER']);
$user->setIsApproved(true);
$user->setIsVerified(true);
$em->persist($user);
$event = new \App\Entity\Event();
$event->setAccount($user);
$event->setTitle('Brocante Geante');
$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);
$em->flush();
// Mock EventIndexService to return the event (simulates Meilisearch match)
$eventIndex = $this->createMock(\App\Service\EventIndexService::class);
$eventIndex->method('searchEvents')->willReturn([$event]);
static::getContainer()->set(\App\Service\EventIndexService::class, $eventIndex);
$client->request('GET', '/evenements?q=Brocante&city=Paris');
self::assertResponseIsSuccessful();
}
public function testEventStockRoute(): void
{
$client = static::createClient();
@@ -202,10 +239,34 @@ class HomeControllerTest extends WebTestCase
$event->setCity('Paris');
$event->setIsOnline(true);
$em->persist($event);
$category = new \App\Entity\Category();
$category->setName('Cat Stock');
$category->setEvent($event);
$em->persist($category);
$billet = new \App\Entity\Billet();
$billet->setName('Entree');
$billet->setCategory($category);
$billet->setPriceHT(1000);
$billet->setQuantity(50);
$em->persist($billet);
$em->flush();
$client->request('GET', '/evenement/'.$event->getId().'/stock');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertSame(50, $data[$billet->getId()]);
}
public function testEventStockNotFound(): void
{
$client = static::createClient();
$client->request('GET', '/evenement/999999/stock');
self::assertResponseIsSuccessful();
self::assertSame('[]', $client->getResponse()->getContent());
}
public function testEventDetailNotFoundReturns404(): void

View File

@@ -68,6 +68,21 @@ class InvitationFlowTest extends WebTestCase
self::assertSame(OrganizerInvitation::STATUS_OPENED, $invitation->getStatus());
}
public function testViewExpiredInvitation(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$invitation = $this->createInvitation($em);
$ref = new \ReflectionProperty($invitation, 'createdAt');
$ref->setValue($invitation, new \DateTimeImmutable('-10 days'));
$em->flush();
$client->request('GET', '/invitation/'.$invitation->getToken());
self::assertResponseIsSuccessful();
self::assertStringContainsString('expiree', $client->getResponse()->getContent());
}
public function testViewInvitationInvalidTokenReturns404(): void
{
$client = static::createClient();