diff --git a/.gitignore b/.gitignore index ff98308..594eabc 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile index 23d3210..22263c3 100644 --- a/Makefile +++ b/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 diff --git a/ansible/deploy.yml b/ansible/deploy.yml index aa304bc..c6d1bf7 100644 --- a/ansible/deploy.yml +++ b/ansible/deploy.yml @@ -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 }}" diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index e97346c..6db4888 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -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: diff --git a/sonar-project.properties b/sonar-project.properties index 41ab243..84d7bfc 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -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 diff --git a/src/Command/MeilisearchConsistencyCommand.php b/src/Command/MeilisearchConsistencyCommand.php index 91aa924..97e061b 100644 --- a/src/Command/MeilisearchConsistencyCommand.php +++ b/src/Command/MeilisearchConsistencyCommand.php @@ -107,6 +107,38 @@ class MeilisearchConsistencyCommand extends Command return Command::SUCCESS; } + /** + * @param list $meiliIds + * @param list $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); + } + }); } } diff --git a/src/Command/TranslateCommand.php b/src/Command/TranslateCommand.php new file mode 100644 index 0000000..2d93b37 --- /dev/null +++ b/src/Command/TranslateCommand.php @@ -0,0 +1,218 @@ +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 $texts + * + * @return array + */ + 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 $array1 + * @param array $array2 + * + * @return array + */ +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; +} diff --git a/templates/email/_order_items_table.html.twig b/templates/email/_order_items_table.html.twig new file mode 100644 index 0000000..5ca19e8 --- /dev/null +++ b/templates/email/_order_items_table.html.twig @@ -0,0 +1,26 @@ + + + + + + + + + + {% for item in order.items %} + + + + + + {% endfor %} + + {% if show_total|default(false) %} + + + + + + + {% endif %} +
BilletQtTotal HT
{{ item.billetName }}{{ item.quantity }}{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} €
Total HT{{ order.totalHTDecimal|number_format(2, ',', ' ') }} €
diff --git a/templates/email/order_cancelled_orga.html.twig b/templates/email/order_cancelled_orga.html.twig index cb9005e..ebd40b3 100644 --- a/templates/email/order_cancelled_orga.html.twig +++ b/templates/email/order_cancelled_orga.html.twig @@ -30,24 +30,7 @@ - - - - - - - - - - {% for item in order.items %} - - - - - - {% endfor %} - -
BilletQtTotal HT
{{ item.billetName }}{{ item.quantity }}{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} €
+ {{ include('email/_order_items_table.html.twig', {order: order}) }}

Les billets associes a cette commande ont ete invalides. Vous pouvez consulter le detail depuis votre espace organisateur.

{% endblock %} diff --git a/templates/email/order_notification_orga.html.twig b/templates/email/order_notification_orga.html.twig index 8c3d5a8..953db92 100644 --- a/templates/email/order_notification_orga.html.twig +++ b/templates/email/order_notification_orga.html.twig @@ -26,30 +26,7 @@ - - - - - - - - - - {% for item in order.items %} - - - - - - {% endfor %} - - - - - - - -
BilletQtTotal HT
{{ item.billetName }}{{ item.quantity }}{{ item.lineTotalHTDecimal|number_format(2, ',', ' ') }} €
Total HT{{ order.totalHTDecimal|number_format(2, ',', ' ') }} €
+ {{ include('email/_order_items_table.html.twig', {order: order, show_total: true}) }}

Vous pouvez consulter le detail de cette commande depuis votre espace organisateur, onglet Statistiques.

{% endblock %} diff --git a/tests/Command/TranslateCommandTest.php b/tests/Command/TranslateCommandTest.php new file mode 100644 index 0000000..8aa3cb1 --- /dev/null +++ b/tests/Command/TranslateCommandTest.php @@ -0,0 +1,340 @@ +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); + } +} diff --git a/tests/Controller/AccountControllerTest.php b/tests/Controller/AccountControllerTest.php index ea45324..ec753ba 100644 --- a/tests/Controller/AccountControllerTest.php +++ b/tests/Controller/AccountControllerTest.php @@ -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 $roles */ diff --git a/tests/Controller/OrderControllerTest.php b/tests/Controller/OrderControllerTest.php index 9699e63..3025645 100644 --- a/tests/Controller/OrderControllerTest.php +++ b/tests/Controller/OrderControllerTest.php @@ -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); + } } diff --git a/tests/js/cart.test.js b/tests/js/cart.test.js index ba5834b..a3755c0 100644 --- a/tests/js/cart.test.js +++ b/tests/js/cart.test.js @@ -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 = ` +
+
+ + + + +
+ + +
+ ` + 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 = ` +
+
+ + + + +
+ +
+ ` + initCart() + + await new Promise(r => setTimeout(r, 20)) + + // No crash, max updated + expect(document.querySelector('[data-cart-item]').dataset.max).toBe('5') + }) }) diff --git a/tests/js/editor.test.js b/tests/js/editor.test.js index 0b91b3a..a35745a 100644 --- a/tests/js/editor.test.js +++ b/tests/js/editor.test.js @@ -76,6 +76,11 @@ describe('sanitizeHtml', () => { expect(sanitizeHtml(html)).toBe('

OK

') }) + it('ignores comment nodes', () => { + const html = '

Before

After

' + expect(sanitizeHtml(html)).toBe('

Before

After

') + }) + it('strips href from anchor but keeps text', () => { const html = 'Click' expect(sanitizeHtml(html)).toBe('Click') diff --git a/tests/js/tabs.test.js b/tests/js/tabs.test.js index fb753ed..a168f49 100644 --- a/tests/js/tabs.test.js +++ b/tests/js/tabs.test.js @@ -56,6 +56,73 @@ describe('initTabs', () => { describe('ARIA attributes', () => { beforeEach(() => setup()) + it('handles tabs without parent element', () => { + document.body.innerHTML = ` + +
Content X
+ ` + 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 = ` +
+ +
+
Content
+ ` + 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 = ` +
+ +
+
Content
+ ` + initTabs() + + expect(document.querySelector('[data-tab="tab-keep"]').id).toBe('my-custom-id') + }) + + it('handles missing panel gracefully', () => { + document.body.innerHTML = ` +
+ + +
+
Content
+ ` + 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 = ` +
+ + +
+
Content
+ ` + 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') })