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:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -25,6 +25,14 @@ node_modules/
|
||||
/config/cert/private-key.pem
|
||||
/public/media/
|
||||
/coverage/
|
||||
/coverage-clover.xml
|
||||
|
||||
###> translations (auto-generated, only FR is source) ###
|
||||
/translations/*.en.yaml
|
||||
/translations/*.es.yaml
|
||||
/translations/*.de.yaml
|
||||
/translations/*.it.yaml
|
||||
###< translations ###
|
||||
|
||||
###> friendsofphp/php-cs-fixer ###
|
||||
/.php-cs-fixer.php
|
||||
|
||||
9
Makefile
9
Makefile
@@ -93,6 +93,15 @@ pwa_dev: ## Compile les assets PWA en dev via Docker
|
||||
pwa_prod: ## Compile les assets PWA en prod via Docker
|
||||
docker compose -f docker-compose-prod.yml exec php php bin/console pwa:compile --env=prod
|
||||
|
||||
## —— Traductions ——————————————————————————————————
|
||||
trans: ## Traduit les fichiers YAML FR vers EN/ES/DE/IT via LibreTranslate (Docker dev)
|
||||
docker compose -f docker-compose-dev.yml exec php php bin/console app:translate
|
||||
docker compose -f docker-compose-dev.yml stop libretranslate
|
||||
|
||||
trans_prod: ## Traduit les fichiers YAML FR vers EN/ES/DE/IT via LibreTranslate (Docker prod)
|
||||
docker compose -f docker-compose-prod.yml exec -T php php bin/console app:translate --env=prod
|
||||
docker compose -f docker-compose-prod.yml stop libretranslate
|
||||
|
||||
## —— Qualite ——————————————————————————————————————
|
||||
hadolint: ## Lance Hadolint sur les Dockerfiles
|
||||
hadolint docker/php/dev/Dockerfile
|
||||
|
||||
@@ -130,6 +130,16 @@
|
||||
args:
|
||||
chdir: /var/www/e-ticket
|
||||
|
||||
- name: Start LibreTranslate for translation
|
||||
command: docker compose -f docker-compose-prod.yml up -d libretranslate
|
||||
args:
|
||||
chdir: /var/www/e-ticket
|
||||
|
||||
- name: Translate YAML files (FR → EN/ES/DE/IT) and stop LibreTranslate
|
||||
command: make trans_prod
|
||||
args:
|
||||
chdir: /var/www/e-ticket
|
||||
|
||||
- name: Ensure uploads directories exist with correct permissions
|
||||
file:
|
||||
path: "/var/www/e-ticket/public/uploads/{{ item }}"
|
||||
|
||||
@@ -136,6 +136,21 @@ services:
|
||||
volumes:
|
||||
- meilisearch-data:/meili_data
|
||||
|
||||
libretranslate:
|
||||
image: libretranslate/libretranslate:latest
|
||||
container_name: e_ticket_libretranslate
|
||||
environment:
|
||||
LT_LOAD_ONLY: fr,en,es,de,it
|
||||
LT_DISABLE_WEB_UI: "true"
|
||||
volumes:
|
||||
- libretranslate-data:/home/libretranslate/.local
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:5000/languages || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 120s
|
||||
|
||||
redisinsight:
|
||||
image: redis/redisinsight:latest
|
||||
container_name: e_ticket_redisinsight
|
||||
@@ -151,3 +166,4 @@ volumes:
|
||||
bun-modules:
|
||||
vault-data:
|
||||
meilisearch-data:
|
||||
libretranslate-data:
|
||||
|
||||
@@ -13,7 +13,7 @@ sonar.eslint.reportPaths=eslint-report.json
|
||||
sonar.docker.hadolint.reportPaths=hadolint-dev.json,hadolint-prod.json
|
||||
sonar.dependencyCheck.jsonReportPath=dependency-check-report.json
|
||||
sonar.dependencyCheck.htmlReportPath=dependency-check-report.html
|
||||
sonar.cpd.exclusions=templates/pdf/**
|
||||
sonar.cpd.exclusions=templates/pdf/**,src/Entity/**
|
||||
sonar.issue.ignore.multicriteria=e1,e2,e3,e4,e5,e6,e7,e8
|
||||
sonar.issue.ignore.multicriteria.e6.ruleKey=Web:S5725
|
||||
sonar.issue.ignore.multicriteria.e6.resourceKey=templates/order/payment.html.twig
|
||||
|
||||
@@ -107,6 +107,38 @@ class MeilisearchConsistencyCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int|string> $meiliIds
|
||||
* @param list<int|string> $dbIds
|
||||
*
|
||||
* @return array{int, int}
|
||||
*/
|
||||
private function diffAndReport(string $index, array $meiliIds, array $dbIds, bool $fix, SymfonyStyle $io, ?\Closure $fixMissing = null): array
|
||||
{
|
||||
$orphans = array_diff($meiliIds, $dbIds);
|
||||
$missing = array_diff($dbIds, $meiliIds);
|
||||
|
||||
if (\count($orphans) > 0) {
|
||||
$io->text(sprintf(' [%s] %d orphan(s)', $index, \count($orphans)));
|
||||
if ($fix) {
|
||||
$this->meilisearch->deleteDocuments($index, array_values($orphans));
|
||||
}
|
||||
}
|
||||
|
||||
if (\count($missing) > 0) {
|
||||
$io->text(sprintf(' [%s] %d missing', $index, \count($missing)));
|
||||
if ($fix && $fixMissing) {
|
||||
$fixMissing($missing);
|
||||
}
|
||||
}
|
||||
|
||||
if (0 === \count($orphans) && 0 === \count($missing)) {
|
||||
$io->text(sprintf(' [%s] OK (%d documents)', $index, \count($meiliIds)));
|
||||
}
|
||||
|
||||
return [\count($orphans), \count($missing)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{int, int}
|
||||
*/
|
||||
@@ -134,36 +166,18 @@ class MeilisearchConsistencyCommand extends Command
|
||||
|
||||
$dbIds = array_map(fn (Event $e) => $e->getId(), $dbEvents);
|
||||
|
||||
$orphans = array_diff($meiliIds, $dbIds);
|
||||
$missing = array_diff($dbIds, $meiliIds);
|
||||
|
||||
if (\count($orphans) > 0) {
|
||||
$io->text(sprintf(' [%s] %d orphan(s) in Meilisearch', $index, \count($orphans)));
|
||||
if ($fix) {
|
||||
$this->meilisearch->deleteDocuments($index, array_values($orphans));
|
||||
}
|
||||
$eventsById = [];
|
||||
foreach ($dbEvents as $e) {
|
||||
$eventsById[$e->getId()] = $e;
|
||||
}
|
||||
|
||||
if (\count($missing) > 0) {
|
||||
$io->text(sprintf(' [%s] %d missing from Meilisearch', $index, \count($missing)));
|
||||
if ($fix) {
|
||||
$eventsById = [];
|
||||
foreach ($dbEvents as $e) {
|
||||
$eventsById[$e->getId()] = $e;
|
||||
}
|
||||
foreach ($missing as $id) {
|
||||
if (isset($eventsById[$id])) {
|
||||
$this->eventIndex->indexEvent($eventsById[$id]);
|
||||
}
|
||||
return $this->diffAndReport($index, $meiliIds, $dbIds, $fix, $io, function (array $missing) use ($eventsById): void {
|
||||
foreach ($missing as $id) {
|
||||
if (isset($eventsById[$id])) {
|
||||
$this->eventIndex->indexEvent($eventsById[$id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (0 === \count($orphans) && 0 === \count($missing)) {
|
||||
$io->text(sprintf(' [%s] OK (%d documents)', $index, \count($meiliIds)));
|
||||
}
|
||||
|
||||
return [\count($orphans), \count($missing)];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,36 +195,18 @@ class MeilisearchConsistencyCommand extends Command
|
||||
$dbOrders = $this->em->getRepository(BilletBuyer::class)->findBy(['event' => $event]);
|
||||
$dbIds = array_map(fn (BilletBuyer $o) => $o->getId(), $dbOrders);
|
||||
|
||||
$orphans = array_diff($meiliIds, $dbIds);
|
||||
$missing = array_diff($dbIds, $meiliIds);
|
||||
|
||||
if (\count($orphans) > 0) {
|
||||
$io->text(sprintf(' [%s] %d orphan(s)', $index, \count($orphans)));
|
||||
if ($fix) {
|
||||
$this->meilisearch->deleteDocuments($index, array_values($orphans));
|
||||
}
|
||||
$ordersById = [];
|
||||
foreach ($dbOrders as $o) {
|
||||
$ordersById[$o->getId()] = $o;
|
||||
}
|
||||
|
||||
if (\count($missing) > 0) {
|
||||
$io->text(sprintf(' [%s] %d missing', $index, \count($missing)));
|
||||
if ($fix) {
|
||||
$ordersById = [];
|
||||
foreach ($dbOrders as $o) {
|
||||
$ordersById[$o->getId()] = $o;
|
||||
}
|
||||
foreach ($missing as $id) {
|
||||
if (isset($ordersById[$id])) {
|
||||
$this->orderIndex->indexOrder($ordersById[$id]);
|
||||
}
|
||||
return $this->diffAndReport($index, $meiliIds, $dbIds, $fix, $io, function (array $missing) use ($ordersById): void {
|
||||
foreach ($missing as $id) {
|
||||
if (isset($ordersById[$id])) {
|
||||
$this->orderIndex->indexOrder($ordersById[$id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (0 === \count($orphans) && 0 === \count($missing)) {
|
||||
$io->text(sprintf(' [%s] OK (%d documents)', $index, \count($meiliIds)));
|
||||
}
|
||||
|
||||
return [\count($orphans), \count($missing)];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,53 +229,35 @@ class MeilisearchConsistencyCommand extends Command
|
||||
|
||||
$dbIds = array_map(fn (User $u) => $u->getId(), $dbUsers);
|
||||
|
||||
$orphans = array_diff($meiliIds, $dbIds);
|
||||
$missing = array_diff($dbIds, $meiliIds);
|
||||
|
||||
if (\count($orphans) > 0) {
|
||||
$io->text(sprintf(' [%s] %d orphan(s)', $index, \count($orphans)));
|
||||
if ($fix) {
|
||||
$this->meilisearch->deleteDocuments($index, array_values($orphans));
|
||||
}
|
||||
$usersById = [];
|
||||
foreach ($dbUsers as $u) {
|
||||
$usersById[$u->getId()] = $u;
|
||||
}
|
||||
|
||||
if (\count($missing) > 0) {
|
||||
$io->text(sprintf(' [%s] %d missing', $index, \count($missing)));
|
||||
if ($fix) {
|
||||
$docs = [];
|
||||
$usersById = [];
|
||||
foreach ($dbUsers as $u) {
|
||||
$usersById[$u->getId()] = $u;
|
||||
}
|
||||
foreach ($missing as $id) {
|
||||
if (isset($usersById[$id])) {
|
||||
$u = $usersById[$id];
|
||||
$doc = [
|
||||
'id' => $u->getId(),
|
||||
'firstName' => $u->getFirstName(),
|
||||
'lastName' => $u->getLastName(),
|
||||
'email' => $u->getEmail(),
|
||||
];
|
||||
if ($isOrganizer) {
|
||||
$doc['companyName'] = $u->getCompanyName();
|
||||
$doc['siret'] = $u->getSiret();
|
||||
$doc['city'] = $u->getCity();
|
||||
} else {
|
||||
$doc['createdAt'] = $u->getCreatedAt()->format('d/m/Y');
|
||||
}
|
||||
$docs[] = $doc;
|
||||
return $this->diffAndReport($index, $meiliIds, $dbIds, $fix, $io, function (array $missing) use ($usersById, $isOrganizer, $index): void {
|
||||
$docs = [];
|
||||
foreach ($missing as $id) {
|
||||
if (isset($usersById[$id])) {
|
||||
$u = $usersById[$id];
|
||||
$doc = [
|
||||
'id' => $u->getId(),
|
||||
'firstName' => $u->getFirstName(),
|
||||
'lastName' => $u->getLastName(),
|
||||
'email' => $u->getEmail(),
|
||||
];
|
||||
if ($isOrganizer) {
|
||||
$doc['companyName'] = $u->getCompanyName();
|
||||
$doc['siret'] = $u->getSiret();
|
||||
$doc['city'] = $u->getCity();
|
||||
} else {
|
||||
$doc['createdAt'] = $u->getCreatedAt()->format('d/m/Y');
|
||||
}
|
||||
}
|
||||
if ([] !== $docs) {
|
||||
$this->meilisearch->addDocuments($index, $docs);
|
||||
$docs[] = $doc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (0 === \count($orphans) && 0 === \count($missing)) {
|
||||
$io->text(sprintf(' [%s] OK (%d documents)', $index, \count($meiliIds)));
|
||||
}
|
||||
|
||||
return [\count($orphans), \count($missing)];
|
||||
if ([] !== $docs) {
|
||||
$this->meilisearch->addDocuments($index, $docs);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
218
src/Command/TranslateCommand.php
Normal file
218
src/Command/TranslateCommand.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:translate',
|
||||
description: 'Translate YAML translation files from French to other languages using LibreTranslate',
|
||||
)]
|
||||
class TranslateCommand extends Command
|
||||
{
|
||||
private const TARGET_LANGUAGES = ['en', 'es', 'de', 'it'];
|
||||
|
||||
public function __construct(
|
||||
private HttpClientInterface $httpClient,
|
||||
#[Autowire('%kernel.project_dir%')] private string $projectDir,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('url', null, InputOption::VALUE_OPTIONAL, 'LibreTranslate API URL', 'http://libretranslate:5000');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$apiUrl = $input->getOption('url');
|
||||
$translationsDir = $this->projectDir.'/translations';
|
||||
|
||||
if (!$this->waitForApi($apiUrl, $io)) {
|
||||
$io->error('LibreTranslate is not reachable at '.$apiUrl);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$frFiles = glob($translationsDir.'/*.fr.yaml');
|
||||
if (!$frFiles) {
|
||||
$io->warning('No French translation files found in '.$translationsDir);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$totalKeys = 0;
|
||||
$totalFiles = 0;
|
||||
|
||||
foreach ($frFiles as $frFile) {
|
||||
$basename = basename($frFile);
|
||||
$domain = str_replace('.fr.yaml', '', $basename);
|
||||
|
||||
$io->section('Translating: '.$basename);
|
||||
|
||||
$frData = Yaml::parseFile($frFile);
|
||||
if (!\is_array($frData) || 0 === \count($frData)) {
|
||||
$io->text(' Skipped (empty)');
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (self::TARGET_LANGUAGES as $lang) {
|
||||
$targetFile = $translationsDir.'/'.$domain.'.'.$lang.'.yaml';
|
||||
|
||||
$existing = [];
|
||||
if (file_exists($targetFile)) {
|
||||
$existing = Yaml::parseFile($targetFile) ?? [];
|
||||
}
|
||||
|
||||
$toTranslate = [];
|
||||
foreach ($frData as $key => $value) {
|
||||
if (!isset($existing[$key]) || '' === $existing[$key]) {
|
||||
$toTranslate[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$removed = array_diff_keys_recursive($existing, $frData);
|
||||
foreach (array_keys($removed) as $key) {
|
||||
unset($existing[$key]);
|
||||
}
|
||||
|
||||
if (0 === \count($toTranslate)) {
|
||||
$io->text(sprintf(' [%s] Already up to date (%d keys)', $lang, \count($frData)));
|
||||
if (\count($removed) > 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;
|
||||
}
|
||||
|
||||
$translated = $this->translateBatch($apiUrl, $toTranslate, $lang, $io);
|
||||
$merged = array_merge($existing, $translated);
|
||||
|
||||
$ordered = [];
|
||||
foreach ($frData as $key => $value) {
|
||||
if (isset($merged[$key])) {
|
||||
$ordered[$key] = $merged[$key];
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $texts
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function translateBatch(string $apiUrl, array $texts, string $targetLang, SymfonyStyle $io): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($texts as $key => $text) {
|
||||
$translated = $this->translateText($apiUrl, (string) $text, $targetLang);
|
||||
if (null !== $translated) {
|
||||
$translated = $this->preservePlaceholders((string) $text, $translated);
|
||||
$result[$key] = $translated;
|
||||
} else {
|
||||
$io->warning(sprintf(' Failed to translate key "%s" to %s', $key, $targetLang));
|
||||
$result[$key] = (string) $text;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function translateText(string $apiUrl, string $text, string $targetLang): ?string
|
||||
{
|
||||
try {
|
||||
$response = $this->httpClient->request('POST', $apiUrl.'/translate', [
|
||||
'json' => [
|
||||
'q' => $text,
|
||||
'source' => 'fr',
|
||||
'target' => $targetLang,
|
||||
'format' => 'text',
|
||||
],
|
||||
'timeout' => 30,
|
||||
]);
|
||||
|
||||
$data = $response->toArray(false);
|
||||
|
||||
return $data['translatedText'] ?? null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function preservePlaceholders(string $original, string $translated): string
|
||||
{
|
||||
preg_match_all('/%[a-zA-Z0-9_]+%/', $original, $matches);
|
||||
if (empty($matches[0])) {
|
||||
return $translated;
|
||||
}
|
||||
|
||||
foreach ($matches[0] as $placeholder) {
|
||||
if (!str_contains($translated, $placeholder)) {
|
||||
$escaped = preg_quote($placeholder, '/');
|
||||
$pattern = '/'.str_replace(['%', '_'], ['%?', '[_ ]?'], $escaped).'/i';
|
||||
$translated = preg_replace($pattern, $placeholder, $translated, 1) ?? $translated;
|
||||
}
|
||||
}
|
||||
|
||||
return $translated;
|
||||
}
|
||||
|
||||
private function waitForApi(string $apiUrl, SymfonyStyle $io): bool
|
||||
{
|
||||
$io->text('Waiting for LibreTranslate at '.$apiUrl.'...');
|
||||
|
||||
for ($i = 0; $i < 30; ++$i) {
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', $apiUrl.'/languages', ['timeout' => 5]);
|
||||
if (200 === $response->getStatusCode()) {
|
||||
return true;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
sleep(2);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
26
templates/email/_order_items_table.html.twig
Normal file
26
templates/email/_order_items_table.html.twig
Normal file
@@ -0,0 +1,26 @@
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||
<thead>
|
||||
<tr style="background: #111827; color: #fff;">
|
||||
<th style="padding: 10px 12px; text-align: left; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Billet</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Qt</th>
|
||||
<th style="padding: 10px 12px; text-align: right; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Total HT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items %}
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 10px 12px; font-weight: 700; font-size: 14px;">{{ item.billetName }}</td>
|
||||
<td style="padding: 10px 12px; text-align: center; font-weight: 700;">{{ item.quantity }}</td>
|
||||
<td style="padding: 10px 12px; text-align: right; font-weight: 900; color: #4f46e5;">{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} €</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% if show_total|default(false) %}
|
||||
<tfoot>
|
||||
<tr style="border-top: 3px solid #111827;">
|
||||
<td colspan="2" style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 13px;">Total HT</td>
|
||||
<td style="padding: 10px 12px; text-align: right; font-weight: 900; font-size: 16px; color: #4f46e5;">{{ order.totalHTDecimal|number_format(2, ',', ' ') }} €</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{% endif %}
|
||||
</table>
|
||||
@@ -30,24 +30,7 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||
<thead>
|
||||
<tr style="background: #111827; color: #fff;">
|
||||
<th style="padding: 10px 12px; text-align: left; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Billet</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Qt</th>
|
||||
<th style="padding: 10px 12px; text-align: right; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Total HT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items %}
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 10px 12px; font-weight: 700; font-size: 14px;">{{ item.billetName }}</td>
|
||||
<td style="padding: 10px 12px; text-align: center; font-weight: 700;">{{ item.quantity }}</td>
|
||||
<td style="padding: 10px 12px; text-align: right; font-weight: 900; color: #4f46e5;">{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} €</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ include('email/_order_items_table.html.twig', {order: order}) }}
|
||||
|
||||
<p style="font-size: 12px; color: #9ca3af;">Les billets associes a cette commande ont ete invalides. Vous pouvez consulter le detail depuis votre espace organisateur.</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -26,30 +26,7 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||
<thead>
|
||||
<tr style="background: #111827; color: #fff;">
|
||||
<th style="padding: 10px 12px; text-align: left; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Billet</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Qt</th>
|
||||
<th style="padding: 10px 12px; text-align: right; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px;">Total HT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items %}
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 10px 12px; font-weight: 700; font-size: 14px;">{{ item.billetName }}</td>
|
||||
<td style="padding: 10px 12px; text-align: center; font-weight: 700;">{{ item.quantity }}</td>
|
||||
<td style="padding: 10px 12px; text-align: right; font-weight: 900; color: #4f46e5;">{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} €</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="border-top: 3px solid #111827;">
|
||||
<td colspan="2" style="padding: 10px 12px; font-weight: 900; text-transform: uppercase; font-size: 13px;">Total HT</td>
|
||||
<td style="padding: 10px 12px; text-align: right; font-weight: 900; font-size: 16px; color: #4f46e5;">{{ order.totalHTDecimal|number_format(2, ',', ' ') }} €</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{{ include('email/_order_items_table.html.twig', {order: order, show_total: true}) }}
|
||||
|
||||
<p style="font-size: 12px; color: #9ca3af;">Vous pouvez consulter le detail de cette commande depuis votre espace organisateur, onglet Statistiques.</p>
|
||||
{% endblock %}
|
||||
|
||||
340
tests/Command/TranslateCommandTest.php
Normal file
340
tests/Command/TranslateCommandTest.php
Normal file
@@ -0,0 +1,340 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Command;
|
||||
|
||||
use App\Command\TranslateCommand;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
class TranslateCommandTest extends TestCase
|
||||
{
|
||||
private string $tmpDir;
|
||||
private string $translationsDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tmpDir = sys_get_temp_dir().'/translate_test_'.uniqid();
|
||||
$this->translationsDir = $this->tmpDir.'/translations';
|
||||
mkdir($this->translationsDir, 0o755, true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$files = glob($this->translationsDir.'/*') ?: [];
|
||||
foreach ($files as $f) {
|
||||
unlink($f);
|
||||
}
|
||||
rmdir($this->translationsDir);
|
||||
rmdir($this->tmpDir);
|
||||
}
|
||||
|
||||
private function createTester(HttpClientInterface $httpClient): CommandTester
|
||||
{
|
||||
$command = new TranslateCommand($httpClient, $this->tmpDir);
|
||||
$app = new Application();
|
||||
$app->addCommand($command);
|
||||
|
||||
return new CommandTester($app->find('app:translate'));
|
||||
}
|
||||
|
||||
private function mockHttpClient(array $translateResponses = [], bool $apiAvailable = true): HttpClientInterface
|
||||
{
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$callIndex = 0;
|
||||
|
||||
$httpClient->method('request')->willReturnCallback(
|
||||
function (string $method, string $url) use (&$callIndex, $translateResponses, $apiAvailable) {
|
||||
$response = $this->createMock(ResponseInterface::class);
|
||||
|
||||
if ('GET' === $method && str_contains($url, '/languages')) {
|
||||
if (!$apiAvailable) {
|
||||
throw new \RuntimeException('Connection refused');
|
||||
}
|
||||
$response->method('getStatusCode')->willReturn(200);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ('POST' === $method && str_contains($url, '/translate')) {
|
||||
$translated = $translateResponses[$callIndex] ?? 'Translated text';
|
||||
++$callIndex;
|
||||
$response->method('toArray')->willReturn(['translatedText' => $translated]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
);
|
||||
|
||||
return $httpClient;
|
||||
}
|
||||
|
||||
public function testApiUnreachable(): void
|
||||
{
|
||||
$httpClient = $this->mockHttpClient([], false);
|
||||
$tester = $this->createTester($httpClient);
|
||||
|
||||
$tester->execute(['--url' => 'http://fake:5000']);
|
||||
|
||||
self::assertSame(1, $tester->getStatusCode());
|
||||
self::assertStringContainsString('not reachable', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testNoFrenchFiles(): void
|
||||
{
|
||||
$httpClient = $this->mockHttpClient();
|
||||
$tester = $this->createTester($httpClient);
|
||||
|
||||
$tester->execute(['--url' => 'http://fake:5000']);
|
||||
|
||||
self::assertSame(0, $tester->getStatusCode());
|
||||
self::assertStringContainsString('No French translation files', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testEmptyFrenchFile(): void
|
||||
{
|
||||
file_put_contents($this->translationsDir.'/messages.fr.yaml', '');
|
||||
|
||||
$httpClient = $this->mockHttpClient();
|
||||
$tester = $this->createTester($httpClient);
|
||||
|
||||
$tester->execute(['--url' => 'http://fake:5000']);
|
||||
|
||||
self::assertSame(0, $tester->getStatusCode());
|
||||
self::assertStringContainsString('Skipped', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testTranslatesNewKeys(): void
|
||||
{
|
||||
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
|
||||
'hello' => 'Bonjour',
|
||||
'goodbye' => 'Au revoir',
|
||||
]));
|
||||
|
||||
// 2 keys × 4 languages = 8 translations
|
||||
$responses = array_fill(0, 8, null);
|
||||
$responses[0] = 'Hello';
|
||||
$responses[1] = 'Goodbye';
|
||||
$responses[2] = 'Hola';
|
||||
$responses[3] = 'Adios';
|
||||
$responses[4] = 'Hallo';
|
||||
$responses[5] = 'Auf Wiedersehen';
|
||||
$responses[6] = 'Ciao';
|
||||
$responses[7] = 'Arrivederci';
|
||||
|
||||
$httpClient = $this->mockHttpClient($responses);
|
||||
$tester = $this->createTester($httpClient);
|
||||
|
||||
$tester->execute(['--url' => 'http://fake:5000']);
|
||||
|
||||
self::assertSame(0, $tester->getStatusCode());
|
||||
self::assertStringContainsString('Translated 2 new keys', $tester->getDisplay());
|
||||
|
||||
// Check EN file
|
||||
$enData = Yaml::parseFile($this->translationsDir.'/messages.en.yaml');
|
||||
self::assertSame('Hello', $enData['hello']);
|
||||
self::assertSame('Goodbye', $enData['goodbye']);
|
||||
|
||||
// Check ES file
|
||||
$esData = Yaml::parseFile($this->translationsDir.'/messages.es.yaml');
|
||||
self::assertSame('Hola', $esData['hello']);
|
||||
|
||||
// Check DE file
|
||||
self::assertFileExists($this->translationsDir.'/messages.de.yaml');
|
||||
|
||||
// Check IT file
|
||||
self::assertFileExists($this->translationsDir.'/messages.it.yaml');
|
||||
}
|
||||
|
||||
public function testSkipsExistingKeys(): void
|
||||
{
|
||||
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
|
||||
'hello' => 'Bonjour',
|
||||
'new_key' => 'Nouveau',
|
||||
]));
|
||||
|
||||
file_put_contents($this->translationsDir.'/messages.en.yaml', Yaml::dump([
|
||||
'hello' => 'Hello (manual)',
|
||||
]));
|
||||
|
||||
// Only new_key needs translating for EN, then 2 keys for ES/DE/IT = 7
|
||||
$responses = [];
|
||||
$responses[0] = 'New'; // new_key → EN
|
||||
$responses[1] = 'Hola'; // hello → ES
|
||||
$responses[2] = 'Nuevo'; // new_key → ES
|
||||
$responses[3] = 'Hallo'; // hello → DE
|
||||
$responses[4] = 'Neu'; // new_key → DE
|
||||
$responses[5] = 'Ciao'; // hello → IT
|
||||
$responses[6] = 'Nuovo'; // new_key → IT
|
||||
|
||||
$httpClient = $this->mockHttpClient($responses);
|
||||
$tester = $this->createTester($httpClient);
|
||||
|
||||
$tester->execute(['--url' => 'http://fake:5000']);
|
||||
|
||||
self::assertSame(0, $tester->getStatusCode());
|
||||
|
||||
$enData = Yaml::parseFile($this->translationsDir.'/messages.en.yaml');
|
||||
self::assertSame('Hello (manual)', $enData['hello']);
|
||||
self::assertSame('New', $enData['new_key']);
|
||||
}
|
||||
|
||||
public function testRemovesObsoleteKeys(): void
|
||||
{
|
||||
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
|
||||
'hello' => 'Bonjour',
|
||||
]));
|
||||
|
||||
file_put_contents($this->translationsDir.'/messages.en.yaml', Yaml::dump([
|
||||
'hello' => 'Hello',
|
||||
'removed_key' => 'This was removed from FR',
|
||||
]));
|
||||
|
||||
$httpClient = $this->mockHttpClient();
|
||||
$tester = $this->createTester($httpClient);
|
||||
|
||||
$tester->execute(['--url' => 'http://fake:5000']);
|
||||
|
||||
self::assertSame(0, $tester->getStatusCode());
|
||||
|
||||
$enData = Yaml::parseFile($this->translationsDir.'/messages.en.yaml');
|
||||
self::assertArrayHasKey('hello', $enData);
|
||||
self::assertArrayNotHasKey('removed_key', $enData);
|
||||
self::assertStringContainsString('Removed 1 obsolete', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testAlreadyUpToDate(): void
|
||||
{
|
||||
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
|
||||
'hello' => 'Bonjour',
|
||||
]));
|
||||
|
||||
foreach (['en', 'es', 'de', 'it'] as $lang) {
|
||||
file_put_contents($this->translationsDir.'/messages.'.$lang.'.yaml', Yaml::dump([
|
||||
'hello' => 'Translated',
|
||||
]));
|
||||
}
|
||||
|
||||
$httpClient = $this->mockHttpClient();
|
||||
$tester = $this->createTester($httpClient);
|
||||
|
||||
$tester->execute(['--url' => 'http://fake:5000']);
|
||||
|
||||
self::assertSame(0, $tester->getStatusCode());
|
||||
self::assertStringContainsString('Already up to date', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testPreservesPlaceholders(): void
|
||||
{
|
||||
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
|
||||
'retry' => 'Reessayez dans %minutes% minutes.',
|
||||
]));
|
||||
|
||||
// LibreTranslate might mangle the placeholder
|
||||
$responses = [
|
||||
'Try again in % minutes % minutes.', // EN - mangled
|
||||
'Inténtelo de nuevo en %minutes% minutos.', // ES - kept
|
||||
'Versuchen Sie es in % minuten % Minuten erneut.', // DE - mangled
|
||||
'Riprova tra %minutes% minuti.', // IT - kept
|
||||
];
|
||||
|
||||
$httpClient = $this->mockHttpClient($responses);
|
||||
$tester = $this->createTester($httpClient);
|
||||
|
||||
$tester->execute(['--url' => 'http://fake:5000']);
|
||||
|
||||
self::assertSame(0, $tester->getStatusCode());
|
||||
|
||||
$esData = Yaml::parseFile($this->translationsDir.'/messages.es.yaml');
|
||||
self::assertStringContainsString('%minutes%', $esData['retry']);
|
||||
}
|
||||
|
||||
public function testTranslationApiFailureFallbacksToOriginal(): void
|
||||
{
|
||||
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
|
||||
'error' => 'Une erreur est survenue.',
|
||||
]));
|
||||
|
||||
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$callCount = 0;
|
||||
|
||||
$httpClient->method('request')->willReturnCallback(
|
||||
function (string $method, string $url) use (&$callCount) {
|
||||
$response = $this->createMock(ResponseInterface::class);
|
||||
|
||||
if ('GET' === $method && str_contains($url, '/languages')) {
|
||||
$response->method('getStatusCode')->willReturn(200);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ('POST' === $method && str_contains($url, '/translate')) {
|
||||
++$callCount;
|
||||
throw new \RuntimeException('API error');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
);
|
||||
|
||||
$tester = $this->createTester($httpClient);
|
||||
$tester->execute(['--url' => 'http://fake:5000']);
|
||||
|
||||
self::assertSame(0, $tester->getStatusCode());
|
||||
self::assertStringContainsString('Failed to translate', $tester->getDisplay());
|
||||
|
||||
// Fallback: original FR text used
|
||||
$enData = Yaml::parseFile($this->translationsDir.'/messages.en.yaml');
|
||||
self::assertSame('Une erreur est survenue.', $enData['error']);
|
||||
}
|
||||
|
||||
public function testMultipleDomainFiles(): void
|
||||
{
|
||||
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
|
||||
'hello' => 'Bonjour',
|
||||
]));
|
||||
file_put_contents($this->translationsDir.'/security.fr.yaml', Yaml::dump([
|
||||
'login' => 'Connexion',
|
||||
]));
|
||||
|
||||
$httpClient = $this->mockHttpClient(array_fill(0, 8, 'Translated'));
|
||||
$tester = $this->createTester($httpClient);
|
||||
|
||||
$tester->execute(['--url' => 'http://fake:5000']);
|
||||
|
||||
self::assertSame(0, $tester->getStatusCode());
|
||||
|
||||
self::assertFileExists($this->translationsDir.'/messages.en.yaml');
|
||||
self::assertFileExists($this->translationsDir.'/security.en.yaml');
|
||||
self::assertFileExists($this->translationsDir.'/messages.it.yaml');
|
||||
self::assertFileExists($this->translationsDir.'/security.it.yaml');
|
||||
}
|
||||
|
||||
public function testKeysOrderMatchesFrench(): void
|
||||
{
|
||||
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
|
||||
'aaa' => 'Premier',
|
||||
'zzz' => 'Dernier',
|
||||
'mmm' => 'Milieu',
|
||||
]));
|
||||
|
||||
$responses = ['First', 'Last', 'Middle'];
|
||||
// Repeat for 4 languages
|
||||
$allResponses = array_merge($responses, $responses, $responses, $responses);
|
||||
|
||||
$httpClient = $this->mockHttpClient($allResponses);
|
||||
$tester = $this->createTester($httpClient);
|
||||
|
||||
$tester->execute(['--url' => 'http://fake:5000']);
|
||||
|
||||
$enData = Yaml::parseFile($this->translationsDir.'/messages.en.yaml');
|
||||
$keys = array_keys($enData);
|
||||
|
||||
self::assertSame(['aaa', 'zzz', 'mmm'], $keys);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +287,52 @@ describe('initCart', () => {
|
||||
expect(document.getElementById('cart-error-text').textContent).toContain('erreur')
|
||||
})
|
||||
|
||||
it('handles checkout without error elements', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('fail'))
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
document.body.innerHTML = `
|
||||
<div id="billetterie">
|
||||
<div data-cart-item data-billet-id="1" data-price="10" data-max="5">
|
||||
<button data-cart-minus></button>
|
||||
<input data-cart-qty type="number" min="0" max="5" value="0" readonly>
|
||||
<button data-cart-plus></button>
|
||||
<span data-cart-line-total></span>
|
||||
</div>
|
||||
<span id="cart-total"></span><span id="cart-count"></span>
|
||||
<button id="cart-checkout" disabled data-order-url="/order"></button>
|
||||
</div>
|
||||
`
|
||||
initCart()
|
||||
|
||||
document.querySelector('[data-cart-plus]').click()
|
||||
document.getElementById('cart-checkout').click()
|
||||
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
|
||||
expect(document.getElementById('cart-checkout').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('hides error on new checkout attempt', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ redirect: '/ok' }),
|
||||
})
|
||||
globalThis.fetch = fetchMock
|
||||
globalThis.location = { href: '' }
|
||||
|
||||
createBilletterie([{ id: 1, price: '10.00', max: 5 }])
|
||||
initCart()
|
||||
|
||||
const errorEl = document.getElementById('cart-error')
|
||||
errorEl.classList.remove('hidden')
|
||||
|
||||
document.querySelector('[data-cart-plus]').click()
|
||||
document.getElementById('cart-checkout').click()
|
||||
|
||||
expect(errorEl.classList.contains('hidden')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not post without order url', () => {
|
||||
const fetchMock = vi.fn()
|
||||
globalThis.fetch = fetchMock
|
||||
@@ -407,4 +453,83 @@ describe('stock polling', () => {
|
||||
// No crash, label unchanged
|
||||
expect(document.querySelector('[data-stock-label]').innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('skips billet not in stock response', async () => {
|
||||
globalThis.fetch = mockStock({ 999: 10 })
|
||||
globalThis.setInterval = (fn) => { fn(); return 1 }
|
||||
|
||||
createBilletterie([{ id: 1, price: '10.00', max: 5 }], '/stock')
|
||||
initCart()
|
||||
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
// Label unchanged — billet 1 not in response
|
||||
expect(document.querySelector('[data-stock-label]').innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('handles out of stock when qty already zero', async () => {
|
||||
globalThis.fetch = mockStock({ 1: 0 })
|
||||
globalThis.setInterval = (fn) => { fn(); return 1 }
|
||||
|
||||
createBilletterie([{ id: 1, price: '10.00', max: 5 }], '/stock')
|
||||
initCart()
|
||||
|
||||
// Don't click +, qty stays at 0
|
||||
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
expect(document.querySelector('[data-stock-label]').innerHTML).toContain('Rupture')
|
||||
expect(document.querySelector('[data-cart-qty]').value).toBe('0')
|
||||
})
|
||||
|
||||
it('shows singular place for stock of 1', async () => {
|
||||
globalThis.fetch = mockStock({ 1: 1 })
|
||||
globalThis.setInterval = (fn) => { fn(); return 1 }
|
||||
|
||||
createBilletterie([{ id: 1, price: '10.00', max: 5 }], '/stock')
|
||||
initCart()
|
||||
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
const label = document.querySelector('[data-stock-label]')
|
||||
expect(label.innerHTML).toContain('Plus que 1 place !')
|
||||
expect(label.innerHTML).not.toContain('places')
|
||||
})
|
||||
|
||||
it('shows singular for stock of exactly 1 in normal range', async () => {
|
||||
globalThis.fetch = mockStock({ 1: 50 })
|
||||
globalThis.setInterval = (fn) => { fn(); return 1 }
|
||||
|
||||
createBilletterie([{ id: 1, price: '10.00', max: 100 }], '/stock')
|
||||
initCart()
|
||||
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
const label = document.querySelector('[data-stock-label]')
|
||||
expect(label.innerHTML).toContain('50 places disponibles')
|
||||
})
|
||||
|
||||
it('handles item without stock-label element', async () => {
|
||||
globalThis.fetch = mockStock({ 1: 5 })
|
||||
globalThis.setInterval = (fn) => { fn(); return 1 }
|
||||
|
||||
// Create billetterie without data-stock-label
|
||||
document.body.innerHTML = `
|
||||
<div id="billetterie" data-stock-url="/stock">
|
||||
<div data-cart-item data-billet-id="1" data-price="10.00" data-max="20">
|
||||
<button data-cart-minus></button>
|
||||
<input data-cart-qty type="number" min="0" max="20" value="0" readonly>
|
||||
<button data-cart-plus></button>
|
||||
<span data-cart-line-total></span>
|
||||
</div>
|
||||
<span id="cart-total"></span><span id="cart-count"></span>
|
||||
</div>
|
||||
`
|
||||
initCart()
|
||||
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
// No crash, max updated
|
||||
expect(document.querySelector('[data-cart-item]').dataset.max).toBe('5')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,6 +76,11 @@ describe('sanitizeHtml', () => {
|
||||
expect(sanitizeHtml(html)).toBe('<p>OK</p>')
|
||||
})
|
||||
|
||||
it('ignores comment nodes', () => {
|
||||
const html = '<p>Before</p><!-- comment --><p>After</p>'
|
||||
expect(sanitizeHtml(html)).toBe('<p>Before</p><p>After</p>')
|
||||
})
|
||||
|
||||
it('strips href from anchor but keeps text', () => {
|
||||
const html = '<a href="javascript:alert(1)">Click</a>'
|
||||
expect(sanitizeHtml(html)).toBe('Click')
|
||||
|
||||
@@ -56,6 +56,73 @@ describe('initTabs', () => {
|
||||
describe('ARIA attributes', () => {
|
||||
beforeEach(() => setup())
|
||||
|
||||
it('handles tabs without parent element', () => {
|
||||
document.body.innerHTML = `
|
||||
<button data-tab="tab-x" style="background-color:#111827;color:white;">X</button>
|
||||
<div id="tab-x" style="display:block;">Content X</div>
|
||||
`
|
||||
initTabs()
|
||||
|
||||
const btn = document.querySelector('[data-tab="tab-x"]')
|
||||
expect(btn.getAttribute('role')).toBe('tab')
|
||||
expect(document.body.getAttribute('role')).toBe('tablist')
|
||||
})
|
||||
|
||||
it('generates id for buttons without id', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<button data-tab="tab-noid" style="background-color:#111827;color:white;">No ID</button>
|
||||
</div>
|
||||
<div id="tab-noid" style="display:block;">Content</div>
|
||||
`
|
||||
initTabs()
|
||||
|
||||
const btn = document.querySelector('[data-tab="tab-noid"]')
|
||||
expect(btn.id).toBe('tab-btn-tab-noid')
|
||||
})
|
||||
|
||||
it('keeps existing button id', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<button id="my-custom-id" data-tab="tab-keep" style="background-color:#111827;color:white;">Keep</button>
|
||||
</div>
|
||||
<div id="tab-keep" style="display:block;">Content</div>
|
||||
`
|
||||
initTabs()
|
||||
|
||||
expect(document.querySelector('[data-tab="tab-keep"]').id).toBe('my-custom-id')
|
||||
})
|
||||
|
||||
it('handles missing panel gracefully', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<button data-tab="tab-exists" style="background-color:#111827;color:white;">Exists</button>
|
||||
<button data-tab="tab-missing" style="background-color:white;color:#111827;">Missing</button>
|
||||
</div>
|
||||
<div id="tab-exists" style="display:block;">Content</div>
|
||||
`
|
||||
initTabs()
|
||||
|
||||
const btnMissing = document.querySelector('[data-tab="tab-missing"]')
|
||||
expect(btnMissing.getAttribute('role')).toBe('tab')
|
||||
expect(btnMissing.getAttribute('aria-selected')).toBe('false')
|
||||
})
|
||||
|
||||
it('click on tab with missing panel does not crash', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<button data-tab="tab-ok" style="background-color:#111827;color:white;">OK</button>
|
||||
<button data-tab="tab-nopanel" style="background-color:white;color:#111827;">No Panel</button>
|
||||
</div>
|
||||
<div id="tab-ok" style="display:block;">Content</div>
|
||||
`
|
||||
initTabs()
|
||||
|
||||
const btn = document.querySelector('[data-tab="tab-nopanel"]')
|
||||
expect(() => btn.click()).not.toThrow()
|
||||
expect(btn.getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('sets role=tablist on parent', () => {
|
||||
expect(document.getElementById('tablist').getAttribute('role')).toBe('tablist')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user