Files
e-ticket/tests/Command/TranslateCommandTest.php
Serreau Jovann 42d06dd49f 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>
2026-03-23 11:44:13 +01:00

341 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}