From 36a51c5a548844c40f55cc0fc8c01746028bda25 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Fri, 30 Jan 2026 17:58:12 +0100 Subject: [PATCH] =?UTF-8?q?```=20=E2=9C=A8=20feat(ReserverController):=20A?= =?UTF-8?q?joute=20v=C3=A9rification=20de=20disponibilit=C3=A9=20produit.?= =?UTF-8?q?=20=F0=9F=9B=A0=EF=B8=8F=20refactor(BackupCommand):=20Utilise?= =?UTF-8?q?=20DatabaseDumper=20et=20ZipArchiver.=20=E2=9C=A8=20feat(GitSyn?= =?UTF-8?q?cLogCommand):=20Utilise=20Gemini=20pour=20messages=20plus=20cla?= =?UTF-8?q?irs.=20=E2=9C=A8=20feat(GenerateVideoThumbsCommand):=20Utilise?= =?UTF-8?q?=20VideoThumbnailer=20service.=20=E2=9C=A8=20feat(AppWarmupImag?= =?UTF-8?q?esCommand):=20Utilise=20StorageInterface=20pour=20warmup.=20?= =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20security(nelmio=5Fsecurity):=20Renforce?= =?UTF-8?q?=20la=20s=C3=A9curit=C3=A9=20avec=20des=20en-t=C3=AAtes.=20?= =?UTF-8?q?=F0=9F=94=A7=20chore(caddy):=20Am=C3=A9liore=20la=20configurati?= =?UTF-8?q?on=20de=20Caddy=20pour=20la=20performance.=20=F0=9F=90=9B=20fix?= =?UTF-8?q?(makefile):=20Corrige=20les=20commandes=20de=20test.=20?= =?UTF-8?q?=F0=9F=A7=AA=20chore(.env.test):=20Supprime=20la=20ligne=20vide?= =?UTF-8?q?=20=C3=A0=20la=20fin=20du=20fichier.=20=F0=9F=94=A7=20chore(doc?= =?UTF-8?q?trine):=20Active=20native=5Flazy=5Fobjects.=20=F0=9F=94=A7=20ch?= =?UTF-8?q?ore(cache):=20Ajoute=20un=20cache=20system.=20```?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.test | 2 +- ansible/templates/caddy.j2 | 47 +++-- config/packages/cache.yaml | 1 + config/packages/doctrine.yaml | 4 +- config/packages/nelmio_cors.yaml | 2 +- config/packages/nelmio_security.yaml | 9 +- makefile | 7 + phpunit.dist.xml | 6 +- src/Command/AppWarmupImagesCommand.php | 10 +- src/Command/BackupCommand.php | 84 ++------- src/Command/GenerateVideoThumbsCommand.php | 29 +-- src/Command/GitSyncLogCommand.php | 59 ++---- src/Command/MailCommand.php | 2 - src/Controller/ReserverController.php | 43 +++++ src/Repository/ProductReserveRepository.php | 13 ++ src/Service/AI/GeminiClient.php | 39 ++++ src/Service/Media/VideoThumbnailer.php | 27 +++ src/Service/System/DatabaseDumper.php | 41 +++++ src/Service/System/GitClient.php | 32 ++++ src/Service/System/ZipArchiver.php | 57 ++++++ tests/Command/AppWarmupImagesCommandTest.php | 94 ++++++++++ tests/Command/BackupCommandTest.php | 171 ++++++++++++++++++ tests/Command/CleanCommandTest.php | 107 +++++++++++ tests/Command/DeployConfigCommandTest.php | 135 ++++++++++++++ .../GenerateVideoThumbsCommandTest.php | 87 +++++++++ tests/Command/GitSyncLogCommandTest.php | 138 ++++++++++++++ tests/Command/MailCommandTest.php | 150 +++++++++++++++ tests/Command/MaintenanceCommandTest.php | 64 +++++++ tests/Command/PurgeCommandTest.php | 65 +++++++ tests/Command/PurgeTxtCommandTest.php | 64 +++++++ tests/Command/SearchCommandTest.php | 147 +++++++++++++++ tests/Command/SitemapCommandTest.php | 75 ++++++++ tests/Controller/ReserverControllerTest.php | 106 +++++++++++ .../ProductReserveRepositoryTest.php | 126 +++++++++++++ 34 files changed, 1879 insertions(+), 164 deletions(-) create mode 100644 src/Service/AI/GeminiClient.php create mode 100644 src/Service/Media/VideoThumbnailer.php create mode 100644 src/Service/System/DatabaseDumper.php create mode 100644 src/Service/System/GitClient.php create mode 100644 src/Service/System/ZipArchiver.php create mode 100644 tests/Command/AppWarmupImagesCommandTest.php create mode 100644 tests/Command/BackupCommandTest.php create mode 100644 tests/Command/CleanCommandTest.php create mode 100644 tests/Command/DeployConfigCommandTest.php create mode 100644 tests/Command/GenerateVideoThumbsCommandTest.php create mode 100644 tests/Command/GitSyncLogCommandTest.php create mode 100644 tests/Command/MailCommandTest.php create mode 100644 tests/Command/MaintenanceCommandTest.php create mode 100644 tests/Command/PurgeCommandTest.php create mode 100644 tests/Command/PurgeTxtCommandTest.php create mode 100644 tests/Command/SearchCommandTest.php create mode 100644 tests/Command/SitemapCommandTest.php create mode 100644 tests/Controller/ReserverControllerTest.php create mode 100644 tests/Repository/ProductReserveRepositoryTest.php diff --git a/.env.test b/.env.test index 64bd111..7578cef 100644 --- a/.env.test +++ b/.env.test @@ -1,3 +1,3 @@ # define your env variables for the test env here KERNEL_CLASS='App\Kernel' -APP_SECRET='$ecretf0rt3st' +APP_SECRET='$ecretf0rt3st' \ No newline at end of file diff --git a/ansible/templates/caddy.j2 b/ansible/templates/caddy.j2 index 4b49fd7..d75d3b5 100644 --- a/ansible/templates/caddy.j2 +++ b/ansible/templates/caddy.j2 @@ -1,4 +1,12 @@ etl.ludikevent.fr, intranet.ludikevent.fr, signature.ludikevent.fr, reservation.ludikevent.fr { + # Logs applicatifs + log { + output file {{ path }}/var/log/caddy.log + } + + # Compression (Gzip + Zstd) pour la performance + encode zstd gzip + tls { dns cloudflare KL6pZ-Z_12_zbnM2TtFDIsKM8A-HLPhU5GJJbKTW } @@ -10,44 +18,47 @@ etl.ludikevent.fr, intranet.ludikevent.fr, signature.ludikevent.fr, reservation. max_size 100MB } - # --- NO-INDEX MATCHER --- + # --- SÉCURITÉ & HEADERS --- + header { + # Headers de sécurité + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + + # Masquer le serveur pour plus de discrétion + -Server + } + + # --- ROBOTS TAGGING --- @noindex_hosts host intranet.ludikevent.fr signature.ludikevent.fr header @noindex_hosts X-Robots-Tag "noindex, nofollow" @index_host host reservation.ludikevent.fr header @index_host -X-Robots-Tag + # --- REDIRECTIONS --- handle_path /utm_reserve.js { - redir https://tools-security.esy-web.dev/script.js + redir https://tools-security.esy-web.dev/script.js permanent } handle_path /ts.js { - redir https://widget.trustpilot.com/bootstrap/v5/tp.widget.bootstrap.min.js + redir https://widget.trustpilot.com/bootstrap/v5/tp.widget.bootstrap.min.js permanent } - # --- BLOC HEADER AVEC CSP --- - header { - X-Content-Type-Options "nosniff" - X-Frame-Options "DENY" - Referrer-Policy "strict-origin-when-cross-origin" - # Injection des headers Cloudflare pour PHP - # Cela permet à PHP de les lire via $_SERVER['HTTP_CF_CONNECTING_IP'] etc. - CF-Connecting-IP {header.CF-Connecting-IP} - CF-IPCountry {header.CF-IPCountry} - CF-RegionCode {header.CF-RegionCode} - CF-IPCity {header.CF-IPCity} - X-Real-IP {remote_host} - } + # --- ASSETS & CACHE --- + # Réécriture /assets -> /build (Vite/Webpack) handle_path /assets/* { rewrite * /build{path} } + # --- PHP FASTCGI --- - # Ici, Caddy transmet automatiquement tous les headers définis ci-dessus au socket PHP php_fastcgi unix//run/php/php8.4-fpm.sock { read_timeout 300s write_timeout 300s dial_timeout 100s - # Optionnel : Forcer explicitement certains paramètres FastCGI si nécessaire + # Transmission de l'IP réelle Cloudflare à PHP + # Les autres headers Cloudflare (CF-Ray, etc.) sont transmis automatiquement env REMOTE_ADDR {header.CF-Connecting-IP} } } diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index f69be59..5cf7ac1 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -8,6 +8,7 @@ framework: # En production, utilisez un adaptateur de cache rapide et performant comme Redis. # Assurez-vous que votre serveur Redis est accessible. app: cache.adapter.redis + system: cache.adapter.redis default_redis_provider: '%env(REDIS_DSN)%' # Vous pouvez également optimiser les pools personnalisés pour la production si besoin. pools: diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 25138b9..dead5a8 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -9,8 +9,8 @@ doctrine: profiling_collect_backtrace: '%kernel.debug%' use_savepoints: true orm: - auto_generate_proxy_classes: true - enable_lazy_ghost_objects: true + auto_generate_proxy_classes: false + enable_native_lazy_objects: true report_fields_where_declared: true validate_xml_mapping: true naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware diff --git a/config/packages/nelmio_cors.yaml b/config/packages/nelmio_cors.yaml index c766508..829d73b 100644 --- a/config/packages/nelmio_cors.yaml +++ b/config/packages/nelmio_cors.yaml @@ -5,6 +5,6 @@ nelmio_cors: allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] allow_headers: ['Content-Type', 'Authorization'] expose_headers: ['Link'] - max_age: 3600 + max_age: 86400 paths: '^/': null diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml index 33ed4a1..e25abef 100644 --- a/config/packages/nelmio_security.yaml +++ b/config/packages/nelmio_security.yaml @@ -4,6 +4,11 @@ nelmio_security: enabled: true policies: - 'strict-origin-when-cross-origin' + content_type: + nosniff: true + clickjacking: + paths: + '^/.*': DENY permissions_policy: enabled: true policies: @@ -19,6 +24,8 @@ nelmio_security: algorithm: 'sha256' enforce: default-src: ["'self'"] + object-src: ["'none'"] + base-uri: ["'self'"] worker-src: ["'self'"] script-src: - "'self'" @@ -56,5 +63,5 @@ nelmio_security: - "data:" frame-ancestors: ["'none'"] # Optionnel : forcer le passage en HTTPS - upgrade-insecure-requests: false + upgrade-insecure-requests: true diff --git a/makefile b/makefile index 548c602..6626982 100644 --- a/makefile +++ b/makefile @@ -57,6 +57,13 @@ dbtest_migrate: ## Crée la base de données dbtest_remove: ## Crée la base de données @$(CONSOLE) doctrine:database:drop --env=test --force +test: ## Lance les tests via Docker + @$(PHP_EXEC) bin/phpunit + +test-prepare: ## Prépare la base de données de test (création + schema update) + @$(CONSOLE) doctrine:database:create --env=test --if-not-exists + @$(CONSOLE) doctrine:schema:update --force --env=test + # --- Aide --- .PHONY: help help: ## Affiche cet écran d'aide diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 22bd879..8d8c323 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -4,9 +4,9 @@ diff --git a/src/Command/AppWarmupImagesCommand.php b/src/Command/AppWarmupImagesCommand.php index b7196e0..c5b7efa 100644 --- a/src/Command/AppWarmupImagesCommand.php +++ b/src/Command/AppWarmupImagesCommand.php @@ -12,7 +12,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Vich\UploaderBundle\Templating\Helper\UploaderHelper; +use Vich\UploaderBundle\Storage\StorageInterface; #[AsCommand( name: 'app:images:warmup', @@ -25,7 +25,7 @@ class AppWarmupImagesCommand extends Command private CacheManager $cacheManager; private DataManager $dataManager; private FilterManager $filterManager; - private UploaderHelper $uploaderHelper; + private StorageInterface $storage; // Adaptez cette liste à vos filtres définis dans liip_imagine.yaml private const FILTERS = ['webp','logo','product_card','poster_hero']; @@ -36,7 +36,7 @@ class AppWarmupImagesCommand extends Command CacheManager $cacheManager, DataManager $dataManager, FilterManager $filterManager, - UploaderHelper $uploaderHelper + StorageInterface $storage ) { parent::__construct(); $this->productRepository = $productRepository; @@ -44,7 +44,7 @@ class AppWarmupImagesCommand extends Command $this->cacheManager = $cacheManager; $this->dataManager = $dataManager; $this->filterManager = $filterManager; - $this->uploaderHelper = $uploaderHelper; + $this->storage = $storage; } protected function execute(InputInterface $input, OutputInterface $output): int @@ -74,7 +74,7 @@ class AppWarmupImagesCommand extends Command $progressBar->start(); foreach ($entities as $entity) { - $assetPath = $this->uploaderHelper->asset($entity, $fieldName); + $assetPath = $this->storage->resolveUri($entity, $fieldName); if (!$assetPath) { $progressBar->advance(count(self::FILTERS)); diff --git a/src/Command/BackupCommand.php b/src/Command/BackupCommand.php index bae967f..4e10b18 100644 --- a/src/Command/BackupCommand.php +++ b/src/Command/BackupCommand.php @@ -6,6 +6,8 @@ namespace App\Command; use App\Entity\Backup; use App\Service\Mailer\Mailer; +use App\Service\System\DatabaseDumper; +use App\Service\System\ZipArchiver; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -13,7 +15,6 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\HttpKernel\KernelInterface; -use ZipArchive; #[AsCommand(name: 'app:backup', description: 'Sauvegarde complète PostgreSQL + Images')] class BackupCommand extends Command @@ -22,6 +23,8 @@ class BackupCommand extends Command private readonly KernelInterface $kernel, private readonly EntityManagerInterface $entityManager, private readonly Mailer $mailer, + private readonly DatabaseDumper $databaseDumper, + private readonly ZipArchiver $zipArchiver, ?string $name = null ) { parent::__construct($name); @@ -49,51 +52,24 @@ class BackupCommand extends Command } // 1. DUMP POSTGRESQL - $dbUrl = parse_url($_ENV['DATABASE_URL'] ?? ''); - if (!$dbUrl) throw new \Exception("DATABASE_URL non configurée."); - + $dbUrl = $_ENV['DATABASE_URL'] ?? ''; $io->text('Extraction SQL via pg_dump...'); - $command = sprintf( - 'PGPASSWORD=%s pg_dump -h %s -p %s -U %s -F p %s > %s', - escapeshellarg($dbUrl['pass'] ?? ''), - escapeshellarg($dbUrl['host'] ?? '127.0.0.1'), - escapeshellarg((string)($dbUrl['port'] ?? 5432)), - escapeshellarg($dbUrl['user'] ?? ''), - escapeshellarg(ltrim($dbUrl['path'], '/')), - escapeshellarg($sqlDumpPath) - ); - exec($command, $outputExec, $returnCode); - if ($returnCode !== 0) throw new \Exception("Échec pg_dump (Code: $returnCode)"); + $this->databaseDumper->dump($dbUrl, $sqlDumpPath); // 2. CRÉATION DU ZIP $fileName = sprintf('db_backup_%s.zip', date('Y-m-d_H-i')); $zipPath = $backupDir . '/' . $fileName; - $zip = new ZipArchive(); - if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) { - // Ajout SQL - if (file_exists($sqlDumpPath)) { - $zip->addFile($sqlDumpPath, 'database.sql'); - } + $directoriesToZip = [ + 'images' => $imagesDir, + 'pdf' => $pdfDir, + 'seo' => $seoDif, + ]; - // Ajout Images Récursif - if (is_dir($imagesDir)) { - $io->text('Compression du dossier images...'); - $this->addFolderToZip($imagesDir, $zip, 'images'); - } - if (is_dir($pdfDir)) { - $io->text('Compression du dossier pdf...'); - $this->addFolderToZip($pdfDir, $zip, 'pdf'); - } - if (is_dir($seoDif)) { - $io->text('Compression du dossier seo...'); - $this->addFolderToZip($seoDif, $zip, 'seo'); - } - $zip->close(); - } else { - throw new \Exception("Impossible d'initialiser le fichier ZIP."); - } + // On s'assure que les dossiers existent avant de les passer (bien que le service vérifie aussi) + $io->text('Compression des dossiers...'); + $this->zipArchiver->createArchive($zipPath, $sqlDumpPath, $directoriesToZip); // 3. STATISTIQUES ET SUCCÈS $fileSizeMb = round(filesize($zipPath) / (1024 * 1024), 2); @@ -126,38 +102,6 @@ class BackupCommand extends Command return Command::SUCCESS; } - /** - * Ajoute un dossier récursivement en corrigeant les séparateurs de dossiers - */ - private function addFolderToZip(string $folderPath, ZipArchive $zip, string $zipSubFolder): void - { - $files = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS), - \RecursiveIteratorIterator::LEAVES_ONLY - ); - - foreach ($files as $file) { - // 1. On ignore les dossiers (déjà géré par LEAVES_ONLY mais par sécurité) - if ($file->isDir()) { - continue; - } - - $filePath = $file->getRealPath(); - $fileName = $file->getFilename(); - - // 2. EXCLUSION : On ignore les fichiers .gitignore - if ($fileName === '.gitignore') { - continue; - } - - if ($filePath) { - // Calcul du chemin relatif interne (on force les '/' pour la compatibilité ZIP) - $relativeInZip = $zipSubFolder . '/' . str_replace('\\', '/', substr($filePath, strlen($folderPath) + 1)); - - $zip->addFile($filePath, $relativeInZip); - } - } - } private function cleanOldFiles(string $backupDir, SymfonyStyle $io): void { $io->text('Nettoyage historique...'); diff --git a/src/Command/GenerateVideoThumbsCommand.php b/src/Command/GenerateVideoThumbsCommand.php index 5a0b302..b6cbaea 100644 --- a/src/Command/GenerateVideoThumbsCommand.php +++ b/src/Command/GenerateVideoThumbsCommand.php @@ -2,13 +2,13 @@ namespace App\Command; +use App\Service\Media\VideoThumbnailer; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; -use Symfony\Component\Process\Process; #[AsCommand( name: 'app:generate-video-thumbs', @@ -22,7 +22,8 @@ class GenerateVideoThumbsCommand extends Command ]; public function __construct( - private readonly ParameterBagInterface $parameterBag + private readonly ParameterBagInterface $parameterBag, + private readonly VideoThumbnailer $videoThumbnailer ) { parent::__construct(); } @@ -49,27 +50,11 @@ class GenerateVideoThumbsCommand extends Command $io->writeln("Traitement de : " . $pathInfo['basename']); - // Commande FFmpeg : - // -i : fichier d'entrée - // -ss : position (00:00:01 = 1ère seconde pour éviter l'écran noir du début) - // -vframes 1 : extraire une seule image - // -q:v 2 : qualité de l'image (2 à 5 est bon) - $command = [ - 'ffmpeg', - '-y', // Écraser si le fichier existe - '-i', $videoFile, - '-ss', '00:00:01.000', // Capture à 1 seconde - '-vframes', '1', - $thumbPath - ]; - - $process = new Process($command); - $process->run(); - - if ($process->isSuccessful()) { + try { + $this->videoThumbnailer->generateThumbnail($videoFile, $thumbPath); $io->writeln("[OK] Miniature générée : $thumbName"); - } else { - $io->error("Erreur FFmpeg : " . $process->getErrorOutput()); + } catch (\RuntimeException $e) { + $io->error("Erreur FFmpeg : " . $e->getMessage()); } } diff --git a/src/Command/GitSyncLogCommand.php b/src/Command/GitSyncLogCommand.php index 0266042..dea8538 100644 --- a/src/Command/GitSyncLogCommand.php +++ b/src/Command/GitSyncLogCommand.php @@ -2,16 +2,15 @@ namespace App\Command; +use App\Service\AI\GeminiClient; +use App\Service\System\GitClient; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\Process\Process; -use Symfony\Component\HttpKernel\KernelInterface; // Pour le dossier projet +use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; -use GeminiAPI\Client; -use GeminiAPI\Resources\Parts\TextPart; #[AsCommand( name: 'app:git-log-update', @@ -19,14 +18,15 @@ use GeminiAPI\Resources\Parts\TextPart; )] class GitSyncLogCommand extends Command { - private HttpClientInterface $httpClient; private string $projectDir; - // On injecte le Kernel pour le chemin du projet et HttpClient pour Discord - public function __construct(HttpClientInterface $httpClient, KernelInterface $kernel) - { + public function __construct( + private readonly HttpClientInterface $httpClient, + KernelInterface $kernel, + private readonly GitClient $gitClient, + private readonly GeminiClient $geminiClient + ) { parent::__construct(); - $this->httpClient = $httpClient; $this->projectDir = $kernel->getProjectDir(); } @@ -39,25 +39,16 @@ class GitSyncLogCommand extends Command $discordWebhook = 'https://discord.com/api/webhooks/1447983279902031963/O6P5oHVHFe2t2MgjFmOW-tOVrvdLf3JQDPAj8snlgKIrfGc8uJQKAHgqRJJjyoSsFYCR'; // 1. Récupération des infos Git - // On utilise $this->projectDir pour le safe.directory - $gitCmd = sprintf( - 'git config --global --add safe.directory %s && git log -1 --format="%%s|%%ci|%%h"', - $this->projectDir - ); - - $process = Process::fromShellCommandline($gitCmd); - $process->setWorkingDirectory($this->projectDir); // On force le dossier de travail - $process->run(); - - if (!$process->isSuccessful()) { - $io->error("Erreur Git : " . $process->getErrorOutput()); + try { + $gitInfo = $this->gitClient->getLastCommitInfo($this->projectDir); + } catch (\RuntimeException $e) { + $io->error("Erreur Git : " . $e->getMessage()); return Command::FAILURE; } - $outputGit = explode('|', trim($process->getOutput())); - $rawMessage = $outputGit[0] ?? ''; - $commitDate = $outputGit[1] ?? date('Y-m-d H:i:s'); - $commitHash = $outputGit[2] ?? 'unknown'; + $rawMessage = $gitInfo['message']; + $commitDate = $gitInfo['date']; + $commitHash = $gitInfo['hash']; // 2. Détermination du TYPE (feature, fix, optimise, new) $type = 'new'; @@ -79,20 +70,10 @@ class GitSyncLogCommand extends Command // 4. Appel IA Gemini $friendlyMessage = $rawMessage; - try { - $client = new Client("AIzaSyDTPJERlUC47bcvhZU51Lwpqb1uxXS8SIg"); - $model = 'gemini-3-pro-preview'; - - $prompt = "Tu es un expert en communication web pour Ludik Event. Ta mission est de transformer - un message de commit technique en une note de mise à jour élégante pour ton client. - MESSAGE TECHNIQUE : \"$rawMessage\" - DIRECTIVES : Court, positif, pas de 'Voici la phrase', uniquement le résultat final."; - - $response = $client->withV1BetaVersion()->generativeModel($model)->generateContent(new TextPart($prompt)); - if ($response->text()) { - $friendlyMessage = trim($response->text()); - } - } catch (\Exception $e) { + $aiMessage = $this->geminiClient->generateFriendlyMessage($rawMessage); + if ($aiMessage) { + $friendlyMessage = $aiMessage; + } else { $io->warning("L'IA n'a pas pu traiter le message."); } diff --git a/src/Command/MailCommand.php b/src/Command/MailCommand.php index 8f7dacf..66dde07 100644 --- a/src/Command/MailCommand.php +++ b/src/Command/MailCommand.php @@ -13,7 +13,6 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\HttpKernel\KernelInterface; -use Vich\UploaderBundle\Templating\Helper\UploaderHelper; #[AsCommand( name: 'app:mail', @@ -23,7 +22,6 @@ class MailCommand extends Command { public function __construct( private readonly KernelInterface $kernel, - private readonly UploaderHelper $uploaderHelper, private readonly Client $client, private readonly Mailer $mailer, private readonly EntityManagerInterface $entityManager diff --git a/src/Controller/ReserverController.php b/src/Controller/ReserverController.php index 31dfc1c..2e3f47b 100644 --- a/src/Controller/ReserverController.php +++ b/src/Controller/ReserverController.php @@ -7,6 +7,7 @@ use App\Entity\AccountResetPasswordRequest; use App\Entity\Customer; use App\Entity\CustomerTracking; use App\Entity\Product; +use App\Entity\ProductReserve; use App\Entity\SitePerformance; use App\Form\RequestPasswordConfirmType; use App\Form\RequestPasswordRequestType; @@ -15,6 +16,7 @@ use App\Repository\CustomerRepository; use App\Repository\CustomerTrackingRepository; use App\Repository\FormulesRepository; use App\Repository\ProductRepository; +use App\Repository\ProductReserveRepository; use App\Service\Mailer\Mailer; use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent; use App\Service\ResetPassword\Event\ResetPasswordEvent; @@ -66,6 +68,47 @@ class ReserverController extends AbstractController 'formules' => $formules, ]); } + + #[Route('/produit/check', name: 'produit_check', methods: ['GET', 'POST'])] + public function productCheck(Request $request, ProductReserveRepository $productReserveRepository, ProductRepository $productRepository): Response + { + $productId = $request->get('id'); + $startStr = $request->get('start'); + $endStr = $request->get('end'); + + if (!$productId && $request->isMethod('POST')) { + $payload = $request->getPayload(); + $productId = $payload->get('id'); + $startStr = $payload->get('start'); + $endStr = $payload->get('end'); + } + + if (!$productId || !$startStr || !$endStr) { + return new JsonResponse(['error' => 'Missing parameters'], Response::HTTP_BAD_REQUEST); + } + + $product = $productRepository->find($productId); + if (!$product) { + return new JsonResponse(['error' => 'Product not found'], Response::HTTP_NOT_FOUND); + } + + try { + $start = new \DateTimeImmutable($startStr); + $end = new \DateTimeImmutable($endStr); + } catch (\Exception $e) { + return new JsonResponse(['error' => 'Invalid date format'], Response::HTTP_BAD_REQUEST); + } + + $reserve = new ProductReserve(); + $reserve->setProduct($product); + $reserve->setStartAt($start); + $reserve->setEndAt($end); + + $isAvailable = $productReserveRepository->checkAvailability($reserve); + + return new JsonResponse(['dispo' => $isAvailable]); + } + #[Route('/web-vitals', name: 'reservation_web-vitals', methods: ['POST'])] public function webVitals(Request $request, EntityManagerInterface $em): Response { diff --git a/src/Repository/ProductReserveRepository.php b/src/Repository/ProductReserveRepository.php index 85d3a23..ec08336 100644 --- a/src/Repository/ProductReserveRepository.php +++ b/src/Repository/ProductReserveRepository.php @@ -16,6 +16,19 @@ class ProductReserveRepository extends ServiceEntityRepository parent::__construct($registry, ProductReserve::class); } + public function checkAvailability(ProductReserve $productReserve): bool + { + $qb = $this->createQueryBuilder('p'); + $qb->andWhere('p.product = :product') + ->andWhere('p.startAt < :endAt') + ->andWhere('p.endAt > :startAt') + ->setParameter('product', $productReserve->getProduct()) + ->setParameter('startAt', $productReserve->getStartAt()) + ->setParameter('endAt', $productReserve->getEndAt()); + + return count($qb->getQuery()->getResult()) === 0; + } + // /** // * @return ProductReserve[] Returns an array of ProductReserve objects // */ diff --git a/src/Service/AI/GeminiClient.php b/src/Service/AI/GeminiClient.php new file mode 100644 index 0000000..cf66610 --- /dev/null +++ b/src/Service/AI/GeminiClient.php @@ -0,0 +1,39 @@ +client) { + $this->client = new Client($this->apiKey); + } + + $model = 'gemini-3-pro-preview'; + $prompt = "Tu es un expert en communication web pour Ludik Event. Ta mission est de transformer + un message de commit technique en une note de mise à jour élégante pour ton client. + MESSAGE TECHNIQUE : \"$rawMessage\" + DIRECTIVES : Court, positif, pas de 'Voici la phrase', uniquement le résultat final."; + + $response = $this->client->withV1BetaVersion()->generativeModel($model)->generateContent(new TextPart($prompt)); + + return $response->text() ? trim($response->text()) : null; + + } catch (\Exception $e) { + // Log or ignore? Command just ignored it. + return null; + } + } +} + diff --git a/src/Service/Media/VideoThumbnailer.php b/src/Service/Media/VideoThumbnailer.php new file mode 100644 index 0000000..c5104a6 --- /dev/null +++ b/src/Service/Media/VideoThumbnailer.php @@ -0,0 +1,27 @@ +run(); + + if (!$process->isSuccessful()) { + throw new \RuntimeException($process->getErrorOutput()); + } + } +} \ No newline at end of file diff --git a/src/Service/System/DatabaseDumper.php b/src/Service/System/DatabaseDumper.php new file mode 100644 index 0000000..34ea0d4 --- /dev/null +++ b/src/Service/System/DatabaseDumper.php @@ -0,0 +1,41 @@ + %s', + escapeshellarg($dbUrl['pass'] ?? ''), + escapeshellarg($dbUrl['host'] ?? '127.0.0.1'), + escapeshellarg((string)($dbUrl['port'] ?? 5432)), + escapeshellarg($dbUrl['user'] ?? ''), + escapeshellarg(ltrim($dbUrl['path'], '/')), + escapeshellarg($outputPath) + ); + + // Using exec as in original command, but wrapping it here allows mocking the class. + exec($command, $outputExec, $returnCode); + + if ($returnCode !== 0) { + throw new \Exception("Échec pg_dump (Code: $returnCode)"); + } + } +} diff --git a/src/Service/System/GitClient.php b/src/Service/System/GitClient.php new file mode 100644 index 0000000..21f5a8c --- /dev/null +++ b/src/Service/System/GitClient.php @@ -0,0 +1,32 @@ +setWorkingDirectory($workingDir); + $process->run(); + + if (!$process->isSuccessful()) { + throw new \RuntimeException($process->getErrorOutput()); + } + + $outputGit = explode('|', trim($process->getOutput())); + + return [ + 'message' => $outputGit[0] ?? '', + 'date' => $outputGit[1] ?? date('Y-m-d H:i:s'), + 'hash' => $outputGit[2] ?? 'unknown' + ]; + } +} diff --git a/src/Service/System/ZipArchiver.php b/src/Service/System/ZipArchiver.php new file mode 100644 index 0000000..2eaa693 --- /dev/null +++ b/src/Service/System/ZipArchiver.php @@ -0,0 +1,57 @@ +open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) { + // Ajout SQL + if (file_exists($sqlDumpPath)) { + $zip->addFile($sqlDumpPath, 'database.sql'); + } + + // Ajout Dossiers + foreach ($directories as $name => $path) { + if (is_dir($path)) { + $this->addFolderToZip($path, $zip, $name); + } + } + + $zip->close(); + } else { + throw new \Exception("Impossible d'initialiser le fichier ZIP."); + } + } + + private function addFolderToZip(string $folderPath, ZipArchive $zip, string $zipSubFolder): void + { + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($files as $file) { + if ($file->isDir()) { + continue; + } + + $filePath = $file->getRealPath(); + $fileName = $file->getFilename(); + + if ($fileName === '.gitignore') { + continue; + } + + if ($filePath) { + $relativeInZip = $zipSubFolder . '/' . str_replace('\\', '/', substr($filePath, strlen($folderPath) + 1)); + $zip->addFile($filePath, $relativeInZip); + } + } + } +} diff --git a/tests/Command/AppWarmupImagesCommandTest.php b/tests/Command/AppWarmupImagesCommandTest.php new file mode 100644 index 0000000..687d30b --- /dev/null +++ b/tests/Command/AppWarmupImagesCommandTest.php @@ -0,0 +1,94 @@ +createMock(ProductRepository::class); + $optionsRepo = $this->createMock(OptionsRepository::class); + $cacheManager = $this->createMock(CacheManager::class); + $dataManager = $this->createMock(DataManager::class); + $filterManager = $this->createMock(FilterManager::class); + $storage = $this->createMock(StorageInterface::class); + $binary = $this->createMock(BinaryInterface::class); + + // Dummy data + $product = new \stdClass(); + $option = new \stdClass(); + + $productRepo->method('findAll')->willReturn([$product]); + $optionsRepo->method('findAll')->willReturn([$option]); + + // Helper behavior + $storage->method('resolveUri') + ->willReturnMap([ + [$product, 'imageFile', null, '/uploads/product.jpg'], + [$option, 'imageFile', null, '/uploads/option.jpg'], + ]); + + // Note: resolveUri signature is (obj, fieldName, className). willReturnMap matches arguments exactly. + // We need to be careful with arguments. + // The command calls: $this->storage->resolveUri($entity, $fieldName); + // So arguments are: $entity, $fieldName. The third argument is optional (null default). + // PHPUnit willReturnMap might be strict about argument count or we can use `with` and `willReturn`. + + // Let's use `willReturnCallback` or just simpler `willReturn` if we don't care about args, + // but we want distinct return values. + + $storage->method('resolveUri') + ->willReturnCallback(function($entity, $field) use ($product, $option) { + if ($entity === $product && $field === 'imageFile') return '/uploads/product.jpg'; + if ($entity === $option && $field === 'imageFile') return '/uploads/option.jpg'; + return null; + }); + + // Filters defined in Command (must match private const FILTERS) + $filters = ['webp', 'logo', 'product_card', 'poster_hero']; + $filtersCount = count($filters); + + // 2 entities * 4 filters = 8 operations + $cacheManager->expects($this->exactly(2 * $filtersCount))->method('remove'); + $dataManager->expects($this->exactly(2 * $filtersCount))->method('find')->willReturn($binary); + $filterManager->expects($this->exactly(2 * $filtersCount))->method('applyFilter')->willReturn($binary); + $cacheManager->expects($this->exactly(2 * $filtersCount))->method('store'); + + // Instantiate Command + $command = new AppWarmupImagesCommand( + $productRepo, + $optionsRepo, + $cacheManager, + $dataManager, + $filterManager, + $storage + ); + + $application = new Application(); + $application->add($command); + + $command = $application->find('app:images:warmup'); + $commandTester = new CommandTester($command); + + $commandTester->execute([]); + + $commandTester->assertCommandIsSuccessful(); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Régénération FORCÉE du cache LiipImagine', $output); + $this->assertStringContainsString('Toutes les images ont été régénérées avec succès.', $output); + } +} diff --git a/tests/Command/BackupCommandTest.php b/tests/Command/BackupCommandTest.php new file mode 100644 index 0000000..03729b1 --- /dev/null +++ b/tests/Command/BackupCommandTest.php @@ -0,0 +1,171 @@ +kernel = $this->createMock(KernelInterface::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->mailer = $this->createMock(Mailer::class); + $this->databaseDumper = $this->createMock(DatabaseDumper::class); + $this->zipArchiver = $this->createMock(ZipArchiver::class); + } + + public function testExecuteSuccess() + { + // 1. Setup Data + $projectDir = sys_get_temp_dir() . '/backup_test_' . uniqid(); + mkdir($projectDir . '/sauvegarde', 0777, true); + + $this->kernel->method('getProjectDir')->willReturn($projectDir); + + // 2. Expectations + + // EntityManager + $this->entityManager->expects($this->once())->method('persist')->with($this->isInstanceOf(Backup::class)); + $this->entityManager->expects($this->atLeast(2))->method('flush'); // Once at start, once at end + + // Cleanup expectations + // Mocking Query class which might be final. If so, we'll see an error. + // But commonly in Doctrine mocks, we have to deal with this. + $query = $this->createMock(Query::class); + $query->method('setParameter')->willReturnSelf(); + $query->method('execute'); + $this->entityManager->method('createQuery')->willReturn($query); + + // Database Dumper + $this->databaseDumper->expects($this->once()) + ->method('dump') + ->with($this->anything(), $this->stringEndsWith('temp_db.sql')); + + // Zip Archiver + $this->zipArchiver->expects($this->once()) + ->method('createArchive') + ->willReturnCallback(function($zipPath, $sqlPath, $dirs) { + // Create a dummy zip file so filesize() works + touch($zipPath); + }); + + // Mailer + $this->mailer->expects($this->once()) + ->method('send') + ->with( + 'notification@siteconseil.fr', + 'Intranet Ludievent', + $this->stringContains('✅ Sauvegarde'), + 'mails/backup_notification.twig', + $this->callback(function($context) { + return isset($context['backup']) && $context['backup']->getStatus() === 'SUCCESS'; + }) + ); + + // 3. Execution + $command = new BackupCommand( + $this->kernel, + $this->entityManager, + $this->mailer, + $this->databaseDumper, + $this->zipArchiver + ); + + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:backup')); + + $commandTester->execute([]); + + // 4. Assertions + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Sauvegarde réussie', $output); + + // Cleanup + $this->removeDirectory($projectDir); + } + + public function testExecuteFailure() + { + // 1. Setup Data + $projectDir = sys_get_temp_dir() . '/backup_test_fail_' . uniqid(); + mkdir($projectDir . '/sauvegarde', 0777, true); + + $this->kernel->method('getProjectDir')->willReturn($projectDir); + + // 2. Expectations - Fail at Dump + $this->databaseDumper->expects($this->once()) + ->method('dump') + ->willThrowException(new \Exception("Simulated Dump Error")); + + // Mailer - Should send Error notification + $this->mailer->expects($this->once()) + ->method('send') + ->with( + 'notification@siteconseil.fr', + 'Intranet Ludievent', + $this->stringContains('❌ Sauvegarde'), + 'mails/backup_notification.twig', + $this->callback(function($context) { + return isset($context['backup']) + && $context['backup']->getStatus() === 'ERROR' + && $context['backup']->getErrorMessage() === 'Simulated Dump Error'; + }) + ); + + // Cleanup expectations + $query = $this->createMock(Query::class); + $query->method('setParameter')->willReturnSelf(); + $query->method('execute'); + $this->entityManager->method('createQuery')->willReturn($query); + + // 3. Execution + $command = new BackupCommand( + $this->kernel, + $this->entityManager, + $this->mailer, + $this->databaseDumper, + $this->zipArchiver + ); + + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:backup')); + + $commandTester->execute([]); + + // 4. Assertions + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Échec : Simulated Dump Error', $output); + + // Cleanup + $this->removeDirectory($projectDir); + } + + private function removeDirectory($dir) { + if (!is_dir($dir)) return; + $files = array_diff(scandir($dir), array('.','..')); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? $this->removeDirectory("$dir/$file") : unlink("$dir/$file"); + } + rmdir($dir); + } +} \ No newline at end of file diff --git a/tests/Command/CleanCommandTest.php b/tests/Command/CleanCommandTest.php new file mode 100644 index 0000000..0b668dc --- /dev/null +++ b/tests/Command/CleanCommandTest.php @@ -0,0 +1,107 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->qb1 = $this->createMock(QueryBuilder::class); + $this->qb2 = $this->createMock(QueryBuilder::class); + $this->query1 = $this->createMock(Query::class); + $this->query2 = $this->createMock(Query::class); + } + + public function testExecute() + { + // Configure EntityManager to return two different QueryBuilders in sequence + $this->entityManager->expects($this->exactly(2)) + ->method('createQueryBuilder') + ->willReturnOnConsecutiveCalls($this->qb1, $this->qb2); + + // --- Sequence 1: SitePerformance --- + $this->qb1->expects($this->once()) + ->method('delete') + ->with(SitePerformance::class, 'p') + ->willReturnSelf(); + + $this->qb1->expects($this->once()) + ->method('where') + ->with('p.createdAt <= :limit') + ->willReturnSelf(); + + $this->qb1->expects($this->once()) + ->method('setParameter') + ->with('limit', $this->isInstanceOf(\DateTime::class)) + ->willReturnSelf(); + + $this->qb1->expects($this->once()) + ->method('getQuery') + ->willReturn($this->query1); + + $this->query1->expects($this->once()) + ->method('execute') + ->willReturn(5); // Simulate 5 records deleted + + // --- Sequence 2: CustomerTracking --- + $this->qb2->expects($this->once()) + ->method('delete') + ->with(CustomerTracking::class, 't') + ->willReturnSelf(); + + $this->qb2->expects($this->once()) + ->method('where') + ->with('t.createAT <= :limit') + ->willReturnSelf(); + + $this->qb2->expects($this->once()) + ->method('setParameter') + ->with('limit', $this->isInstanceOf(\DateTime::class)) + ->willReturnSelf(); + + $this->qb2->expects($this->once()) + ->method('getQuery') + ->willReturn($this->query2); + + $this->query2->expects($this->once()) + ->method('execute') + ->willReturn(10); // Simulate 10 records deleted + + // Expect final flush + $this->entityManager->expects($this->once()) + ->method('flush'); + + // Instantiate and run Command + $command = new CleanCommand($this->entityManager); + $application = new Application(); + $application->add($command); + + $commandTester = new CommandTester($application->find('app:clean')); + $commandTester->execute([]); + + $output = $commandTester->getDisplay(); + + // Assertions on output + $this->assertStringContainsString('5 entrées de performance supprimées', $output); + $this->assertStringContainsString('10 entrées de tracking supprimées', $output); + $this->assertStringContainsString('Nettoyage terminé avec succès', $output); + } +} diff --git a/tests/Command/DeployConfigCommandTest.php b/tests/Command/DeployConfigCommandTest.php new file mode 100644 index 0000000..a57d170 --- /dev/null +++ b/tests/Command/DeployConfigCommandTest.php @@ -0,0 +1,135 @@ +parameterBag = $this->createMock(ParameterBagInterface::class); + $this->httpClient = $this->createMock(HttpClientInterface::class); + } + + public function testExecuteMissingToken() + { + // Setup + $this->parameterBag->method('get')->willReturn('/tmp'); + + // Remove CLOUDFLARE_DEPLOY from env if it exists (for this test) + $originalEnv = $_ENV['CLOUDFLARE_DEPLOY'] ?? null; + unset($_ENV['CLOUDFLARE_DEPLOY']); + unset($_SERVER['CLOUDFLARE_DEPLOY']); // Safety + + // Execute + $command = new DeployConfigCommand($this->parameterBag, $this->httpClient); + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:deploy:config')); + + $commandTester->execute([]); + + // Restore Env + if ($originalEnv) $_ENV['CLOUDFLARE_DEPLOY'] = $originalEnv; + + // Assert + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('La clé API Cloudflare (CLOUDFLARE_DEPLOY) est manquante', $output); + $this->assertEquals(1, $commandTester->getStatusCode()); + } + + public function testExecuteSuccess() + { + // Setup + $this->parameterBag->method('get')->willReturn(sys_get_temp_dir()); + $_ENV['CLOUDFLARE_DEPLOY'] = 'test_token'; // Mock environment variable + + // --- Mocking Cloudflare API Responses --- + + // 1. Zone ID Request + $zoneResponse = $this->createMock(ResponseInterface::class); + $zoneResponse->method('toArray')->willReturn([ + 'result' => [['id' => 'zone_123']] + ]); + + // 2. Rulesets List Request (Found existing ruleset) + $rulesetsListResponse = $this->createMock(ResponseInterface::class); + $rulesetsListResponse->method('toArray')->willReturn([ + 'result' => [ + ['id' => 'rs_123', 'phase' => 'http_request_cache_settings'] + ] + ]); + + // 3. Get Specific Ruleset Rules + $rulesResponse = $this->createMock(ResponseInterface::class); + $rulesResponse->method('toArray')->willReturn([ + 'result' => ['rules' => []] + ]); + + // 4. Update Ruleset (PUT) + $updateResponse = $this->createMock(ResponseInterface::class); + $updateResponse->method('toArray')->willReturn(['success' => true]); + + // Configure HttpClient Sequence using Consecutive Calls + $this->httpClient->expects($this->exactly(4)) + ->method('request') + ->willReturnOnConsecutiveCalls( + $zoneResponse, + $rulesetsListResponse, + $rulesResponse, + $updateResponse + ); + + // Execute + $command = new DeployConfigCommand($this->parameterBag, $this->httpClient); + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:deploy:config')); + + $commandTester->execute([]); + + // Assert + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Ruleset Cloudflare mis à jour', $output); + $this->assertEquals(0, $commandTester->getStatusCode()); + } + + public function testExecuteZoneNotFound() + { + // Setup + $this->parameterBag->method('get')->willReturn(sys_get_temp_dir()); + $_ENV['CLOUDFLARE_DEPLOY'] = 'test_token'; + + // Zone Request - Empty Result + $zoneResponse = $this->createMock(ResponseInterface::class); + $zoneResponse->method('toArray')->willReturn(['result' => []]); + + $this->httpClient->expects($this->once()) + ->method('request') + ->willReturn($zoneResponse); + + // Execute + $command = new DeployConfigCommand($this->parameterBag, $this->httpClient); + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:deploy:config')); + + $commandTester->execute([]); + + // Assert + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Zone introuvable', $output); + $this->assertEquals(1, $commandTester->getStatusCode()); + } +} diff --git a/tests/Command/GenerateVideoThumbsCommandTest.php b/tests/Command/GenerateVideoThumbsCommandTest.php new file mode 100644 index 0000000..83ece96 --- /dev/null +++ b/tests/Command/GenerateVideoThumbsCommandTest.php @@ -0,0 +1,87 @@ +parameterBag = $this->createMock(ParameterBagInterface::class); + $this->videoThumbnailer = $this->createMock(VideoThumbnailer::class); + + $this->tempDir = sys_get_temp_dir() . '/thumbs_test_' . uniqid(); + mkdir($this->tempDir . '/public/provider/video', 0777, true); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + public function testExecuteVideoNotFound() + { + // Setup mock + $this->parameterBag->method('get')->with('kernel.project_dir')->willReturn($this->tempDir); + + // Execute + $command = new GenerateVideoThumbsCommand($this->parameterBag, $this->videoThumbnailer); + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:generate-video-thumbs')); + + $commandTester->execute([]); + + // Assert + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Vidéo introuvable', $output); + } + + public function testExecuteSuccess() + { + // Setup file + touch($this->tempDir . '/public/provider/video/video.mp4'); + + // Setup mock + $this->parameterBag->method('get')->with('kernel.project_dir')->willReturn($this->tempDir); + + $this->videoThumbnailer->expects($this->once()) + ->method('generateThumbnail') + ->with( + $this->stringEndsWith('video.mp4'), + $this->stringEndsWith('video.jpg') + ); + + // Execute + $command = new GenerateVideoThumbsCommand($this->parameterBag, $this->videoThumbnailer); + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:generate-video-thumbs')); + + $commandTester->execute([]); + + // Assert + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('[OK] Miniature générée', $output); + } + + private function removeDirectory($dir) { + if (!is_dir($dir)) return; + $files = array_diff(scandir($dir), array('.','..')); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? $this->removeDirectory("$dir/$file") : unlink("$dir/$file"); + } + rmdir($dir); + } +} diff --git a/tests/Command/GitSyncLogCommandTest.php b/tests/Command/GitSyncLogCommandTest.php new file mode 100644 index 0000000..b49c5b8 --- /dev/null +++ b/tests/Command/GitSyncLogCommandTest.php @@ -0,0 +1,138 @@ +httpClient = $this->createMock(HttpClientInterface::class); + $this->kernel = $this->createMock(KernelInterface::class); + $this->gitClient = $this->createMock(GitClient::class); + $this->geminiClient = $this->createMock(GeminiClient::class); + + $this->tempDir = sys_get_temp_dir() . '/git_log_test_' . uniqid(); + mkdir($this->tempDir . '/var', 0777, true); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + public function testExecuteNewCommit() + { + // 1. Setup Mocks + $this->kernel->method('getProjectDir')->willReturn($this->tempDir); + + $this->gitClient->expects($this->once()) + ->method('getLastCommitInfo') + ->willReturn([ + 'message' => 'feat: add awesome feature', + 'date' => '2026-01-30 10:00:00', + 'hash' => 'hash123' + ]); + + $this->geminiClient->expects($this->once()) + ->method('generateFriendlyMessage') + ->with('feat: add awesome feature') + ->willReturn('Super nouvelle fonctionnalité ajoutée !'); + + $this->httpClient->expects($this->once()) + ->method('request') + ->with('POST', $this->stringContains('discord.com'), $this->arrayHasKey('json')); + + // 2. Execute + $command = new GitSyncLogCommand( + $this->httpClient, + $this->kernel, + $this->gitClient, + $this->geminiClient + ); + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:git-log-update')); + + $commandTester->execute([]); + + // 3. Assert Output + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Journal client mis à jour avec succès', $output); + + // 4. Assert File Content + $filePath = $this->tempDir . '/var/update.json'; + $this->assertFileExists($filePath); + $data = json_decode(file_get_contents($filePath), true); + $this->assertCount(1, $data); + $this->assertEquals('feature', $data[0]['type']); + $this->assertEquals('Super nouvelle fonctionnalité ajoutée !', $data[0]['message']); + $this->assertEquals('hash123', $data[0]['hash']); + } + + public function testExecuteAlreadyUpToDate() + { + // 1. Setup File + $filePath = $this->tempDir . '/var/update.json'; + file_put_contents($filePath, json_encode([ + ['hash' => 'hash123'] + ])); + + // 2. Setup Mocks + $this->kernel->method('getProjectDir')->willReturn($this->tempDir); + + $this->gitClient->expects($this->once()) + ->method('getLastCommitInfo') + ->willReturn([ + 'message' => 'fix: bug', + 'date' => '2026-01-30 12:00:00', + 'hash' => 'hash123' // Same hash + ]); + + // Gemini & Discord should NOT be called + $this->geminiClient->expects($this->never())->method('generateFriendlyMessage'); + $this->httpClient->expects($this->never())->method('request'); + + // 3. Execute + $command = new GitSyncLogCommand( + $this->httpClient, + $this->kernel, + $this->gitClient, + $this->geminiClient + ); + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:git-log-update')); + + $commandTester->execute([]); + + // 4. Assert Output + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('déjà à jour', $output); + } + + private function removeDirectory($dir) { + if (!is_dir($dir)) return; + $files = array_diff(scandir($dir), array('.','..')); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? $this->removeDirectory("$dir/$file") : unlink("$dir/$file"); + } + rmdir($dir); + } +} diff --git a/tests/Command/MailCommandTest.php b/tests/Command/MailCommandTest.php new file mode 100644 index 0000000..2497b0b --- /dev/null +++ b/tests/Command/MailCommandTest.php @@ -0,0 +1,150 @@ +kernel = $this->createMock(KernelInterface::class); + $this->client = $this->createMock(Client::class); + $this->mailer = $this->createMock(Mailer::class); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->devisRepository = $this->createMock(EntityRepository::class); + $this->contratsRepository = $this->createMock(EntityRepository::class); + + // Setup repository mocks + $this->entityManager->method('getRepository') + ->willReturnMap([ + [Devis::class, $this->devisRepository], + [Contrats::class, $this->contratsRepository], + ]); + } + + public function testExecuteProcessUnsignedDevisDeletion() + { + // Setup Devis to be deleted (> 3 days) + $devis = $this->createMock(Devis::class); + $devis->method('getCreateA')->willReturn(new \DateTimeImmutable('-4 days')); + $devis->method('getNum')->willReturn('D-123'); + // Ensure getDevisLines/Options return iterables + $devis->method('getDevisLines')->willReturn(new ArrayCollection([])); + $devis->method('getDevisOptions')->willReturn(new ArrayCollection([])); + + $this->devisRepository->expects($this->once()) + ->method('findBy') + ->with(['state' => 'wait-sign']) + ->willReturn([$devis]); + + // Expect removal + $this->entityManager->expects($this->once())->method('remove')->with($devis); + + // Mock other calls to return empty to isolate this test case + $this->contratsRepository->method('findBy')->willReturn([]); + + // Mock QueryBuilder for sendEventReminders and Satisfaction which use createQueryBuilder + $qb = $this->createMock(QueryBuilder::class); + $query = $this->createMock(Query::class); + $qb->method('where')->willReturnSelf(); + $qb->method('andWhere')->willReturnSelf(); + $qb->method('setParameter')->willReturnSelf(); + $qb->method('getQuery')->willReturn($query); + $query->method('getResult')->willReturn([]); + + $this->contratsRepository->method('createQueryBuilder')->willReturn($qb); + + // Execute + $command = new MailCommand( + $this->kernel, + $this->client, + $this->mailer, + $this->entityManager + ); + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:mail')); + + $commandTester->execute([]); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Suppression du devis N°D-123', $output); + } + + public function testExecuteProcessUnsignedDevisReminder() + { + // Setup Devis to be reminded (1 day) + $customer = $this->createMock(Customer::class); + $customer->method('getEmail')->willReturn('test@example.com'); + $customer->method('getName')->willReturn('John'); + + $devis = $this->createMock(Devis::class); + $devis->method('getCreateA')->willReturn(new \DateTimeImmutable('-1 day -2 hours')); + $devis->method('getNum')->willReturn('D-456'); + $devis->method('getCustomer')->willReturn($customer); + $devis->method('getSignatureId')->willReturn('sign_123'); + + $this->devisRepository->expects($this->once()) + ->method('findBy') + ->with(['state' => 'wait-sign']) + ->willReturn([$devis]); + + // Expect Mailer call + $this->mailer->expects($this->once()) + ->method('send') + ->with('test@example.com', 'John', $this->stringContains('Devis N°D-456')); + + // Mock other calls to return empty + $this->contratsRepository->method('findBy')->willReturn([]); + + // Mock QueryBuilder + $qb = $this->createMock(QueryBuilder::class); + $query = $this->createMock(Query::class); + $qb->method('where')->willReturnSelf(); + $qb->method('andWhere')->willReturnSelf(); + $qb->method('setParameter')->willReturnSelf(); + $qb->method('getQuery')->willReturn($query); + $query->method('getResult')->willReturn([]); + + $this->contratsRepository->method('createQueryBuilder')->willReturn($qb); + + // Execute + $command = new MailCommand( + $this->kernel, + $this->client, + $this->mailer, + $this->entityManager + ); + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:mail')); + + $commandTester->execute([]); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Relance envoyée pour le devis : D-456', $output); + } +} diff --git a/tests/Command/MaintenanceCommandTest.php b/tests/Command/MaintenanceCommandTest.php new file mode 100644 index 0000000..4c7d71c --- /dev/null +++ b/tests/Command/MaintenanceCommandTest.php @@ -0,0 +1,64 @@ +tempDir = sys_get_temp_dir() . '/maintenance_test_' . uniqid(); + mkdir($this->tempDir . '/var', 0777, true); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + public function testExecuteOn() + { + $command = new MaintenanceCommand($this->tempDir); + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:maintenance')); + + $commandTester->execute(['status' => 'on']); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Mode maintenance ACTIVÉ', $output); + $this->assertFileExists($this->tempDir . '/var/.maintenance'); + } + + public function testExecuteOff() + { + // Ensure file exists first + touch($this->tempDir . '/var/.maintenance'); + + $command = new MaintenanceCommand($this->tempDir); + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:maintenance')); + + $commandTester->execute(['status' => 'off']); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Mode maintenance DÉSACTIVÉ', $output); + $this->assertFileDoesNotExist($this->tempDir . '/var/.maintenance'); + } + + private function removeDirectory($dir) { + if (!is_dir($dir)) return; + $files = array_diff(scandir($dir), array('.','..')); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? $this->removeDirectory("$dir/$file") : unlink("$dir/$file"); + } + rmdir($dir); + } +} diff --git a/tests/Command/PurgeCommandTest.php b/tests/Command/PurgeCommandTest.php new file mode 100644 index 0000000..8119493 --- /dev/null +++ b/tests/Command/PurgeCommandTest.php @@ -0,0 +1,65 @@ +httpClient = $this->createMock(HttpClientInterface::class); + } + + public function testExecuteSuccess() + { + $response = $this->createMock(ResponseInterface::class); + $response->method('toArray')->willReturn(['success' => true]); + + $this->httpClient->expects($this->once()) + ->method('request') + ->with('POST', $this->stringContains('purge_cache'), $this->anything()) + ->willReturn($response); + + $command = new PurgeCommand($this->httpClient, 'zone_id', 'api_token'); + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:purge-cloudflare')); + + $commandTester->execute([]); + + $this->assertStringContainsString('entièrement vidé', $commandTester->getDisplay()); + $this->assertEquals(0, $commandTester->getStatusCode()); + } + + public function testExecuteFailure() + { + $response = $this->createMock(ResponseInterface::class); + $response->method('toArray')->willReturn([ + 'success' => false, + 'errors' => [['message' => 'Simulated API Error']] + ]); + + $this->httpClient->expects($this->once()) + ->method('request') + ->willReturn($response); + + $command = new PurgeCommand($this->httpClient, 'zone_id', 'api_token'); + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:purge-cloudflare')); + + $commandTester->execute([]); + + $this->assertStringContainsString('Erreur Cloudflare : Simulated API Error', $commandTester->getDisplay()); + $this->assertEquals(1, $commandTester->getStatusCode()); + } +} diff --git a/tests/Command/PurgeTxtCommandTest.php b/tests/Command/PurgeTxtCommandTest.php new file mode 100644 index 0000000..e747301 --- /dev/null +++ b/tests/Command/PurgeTxtCommandTest.php @@ -0,0 +1,64 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->productRepository = $this->createMock(EntityRepository::class); + $this->formulesRepository = $this->createMock(EntityRepository::class); + + $this->entityManager->method('getRepository')->willReturnMap([ + [Product::class, $this->productRepository], + [Formules::class, $this->formulesRepository], + ]); + } + + public function testExecute() + { + // 1. Setup Product Data + $product = new Product(); + $product->setDescription('

Description HTML é purger.

'); + + $this->productRepository->method('findAll')->willReturn([$product]); + + // 2. Setup Formules Data + $formule = new Formules(); + $formule->setDescription('
Une autre
description.
'); + + $this->formulesRepository->method('findAll')->willReturn([$formule]); + + // 3. Expect Flush + $this->entityManager->expects($this->once())->method('flush'); + + // 4. Execute + $command = new PurgeTxtCommand($this->entityManager); + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:txt:purge')); + + $commandTester->execute([]); + + // 5. Assertions + $this->assertEquals('Description HTML é purger.', $product->getDescription()); + $this->assertEquals('Une autre description.', $formule->getDescription()); + + $this->assertStringContainsString('Purge terminée', $commandTester->getDisplay()); + } +} diff --git a/tests/Command/SearchCommandTest.php b/tests/Command/SearchCommandTest.php new file mode 100644 index 0000000..906c240 --- /dev/null +++ b/tests/Command/SearchCommandTest.php @@ -0,0 +1,147 @@ +entityManager = $this->createMock(EntityManagerInterface::class); + $this->client = $this->createMock(Client::class); + + $this->accountRepo = $this->createMock(EntityRepository::class); + $this->customerRepo = $this->createMock(EntityRepository::class); + $this->productRepo = $this->createMock(EntityRepository::class); + $this->optionsRepo = $this->createMock(EntityRepository::class); + $this->contratsRepo = $this->createMock(EntityRepository::class); + + $this->entityManager->method('getRepository')->willReturnMap([ + [Account::class, $this->accountRepo], + [Customer::class, $this->customerRepo], + [Product::class, $this->productRepo], + [Options::class, $this->optionsRepo], + [Contrats::class, $this->contratsRepo], + ]); + } + + public function testExecute() + { + // 1. Setup Data + + // Account (one ROOT to skip, one normal to index) + $rootAccount = $this->createMock(Account::class); + $rootAccount->method('getRoles')->willReturn(['ROLE_ROOT', 'ROLE_USER']); + + $adminAccount = $this->createMock(Account::class); + $adminAccount->method('getRoles')->willReturn(['ROLE_ADMIN']); + $adminAccount->method('getId')->willReturn(1); + $adminAccount->method('getName')->willReturn('Admin'); + $adminAccount->method('getFirstName')->willReturn('User'); // surname mapped to getFirstName in command + $adminAccount->method('getEmail')->willReturn('admin@test.com'); + + $this->accountRepo->method('findAll')->willReturn([$rootAccount, $adminAccount]); + + // Customer + $customer = $this->createMock(Customer::class); + $customer->method('getId')->willReturn(10); + $customer->method('getName')->willReturn('Cust'); + $customer->method('getSurname')->willReturn('Omer'); + $customer->method('getSiret')->willReturn('123'); + $customer->method('getCiv')->willReturn('Mr'); + $customer->method('getType')->willReturn('pro'); + $customer->method('getPhone')->willReturn('0102030405'); + $customer->method('getEmail')->willReturn('cust@test.com'); + + $this->customerRepo->method('findAll')->willReturn([$customer]); + + // Product + $product = $this->createMock(Product::class); + $product->method('getId')->willReturn(20); + $product->method('getName')->willReturn('Prod'); + $product->method('getRef')->willReturn('REF001'); + + $this->productRepo->method('findAll')->willReturn([$product]); + + // Options + $option = $this->createMock(Options::class); + $option->method('getId')->willReturn(30); + $option->method('getName')->willReturn('Opt'); + + $this->optionsRepo->method('findAll')->willReturn([$option]); + + // Contrats (Note: command uses findAll on Contrats::class but variable named $options) + $contrat = $this->createMock(Contrats::class); + $contrat->method('getId')->willReturn(40); + $contrat->method('getNumReservation')->willReturn('RES-100'); + + $this->contratsRepo->method('findAll')->willReturn([$contrat]); + + // 2. Expectations + $this->client->expects($this->once())->method('init'); + + $capturedArgs = []; + $this->client->expects($this->exactly(5)) + ->method('indexDocuments') + ->willReturnCallback(function($data, $index) use (&$capturedArgs) { + $capturedArgs[] = [$data, $index]; + return true; + }); + + // 3. Execute + $command = new SearchCommand($this->entityManager, $this->client); + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:search')); + + $commandTester->execute([]); + + $this->assertStringContainsString('Indexation terminée', $commandTester->getDisplay()); + + // Check captured args + $this->assertCount(5, $capturedArgs); + + // Admin + $this->assertEquals(['id' => 1, 'name' => 'Admin', 'surname' => 'User', 'email' => 'admin@test.com'], $capturedArgs[0][0]); + $this->assertEquals('admin', $capturedArgs[0][1]); + + // Customer + $this->assertEquals(['id' => 10, 'name' => 'Cust', 'surname' => 'Omer', 'siret' => '123', 'civ' => 'Mr', 'type' => 'pro', 'phone' => '0102030405', 'email' => 'cust@test.com'], $capturedArgs[1][0]); + $this->assertEquals('customer', $capturedArgs[1][1]); + + // Product + $this->assertEquals(['id' => 20, 'name' => 'Prod', 'ref' => 'REF001'], $capturedArgs[2][0]); + $this->assertEquals('product', $capturedArgs[2][1]); + + // Options + $this->assertEquals(['id' => 30, 'name' => 'Opt'], $capturedArgs[3][0]); + $this->assertEquals('options', $capturedArgs[3][1]); + + // Contrat + $this->assertEquals(['id' => 40, 'num' => 'RES-100'], $capturedArgs[4][0]); + $this->assertEquals('contrat', $capturedArgs[4][1]); + } +} diff --git a/tests/Command/SitemapCommandTest.php b/tests/Command/SitemapCommandTest.php new file mode 100644 index 0000000..61780db --- /dev/null +++ b/tests/Command/SitemapCommandTest.php @@ -0,0 +1,75 @@ +kernel = $this->createMock(KernelInterface::class); + $this->dumper = $this->createMock(DumperInterface::class); + + $this->tempDir = sys_get_temp_dir() . '/sitemap_test_' . uniqid(); + mkdir($this->tempDir . '/public/seo', 0777, true); + $_ENV['DEFAULT_URI'] = 'https://test.com'; + } + + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + public function testExecute() + { + // 1. Setup Files (Old sitemap to delete) + touch($this->tempDir . '/public/seo/old.xml'); + + $this->kernel->method('getProjectDir')->willReturn($this->tempDir); + + // 2. Expect Dumper call + $this->dumper->expects($this->once()) + ->method('dump') + ->with( + $this->stringEndsWith('/public/seo'), + 'https://test.com/seo', + '', + [] + ); + + // 3. Execute + $command = new SitemapCommand($this->kernel, $this->dumper); + $application = new Application(); + $application->add($command); + $commandTester = new CommandTester($application->find('app:sitemap')); + + $commandTester->execute([]); + + // 4. Assertions + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Anciens fichiers sitemap supprimés', $output); + $this->assertStringContainsString('Sitemap généré avec succès', $output); + + $this->assertFileDoesNotExist($this->tempDir . '/public/seo/old.xml'); + } + + private function removeDirectory($dir) { + if (!is_dir($dir)) return; + $files = array_diff(scandir($dir), array('.','..')); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? $this->removeDirectory("$dir/$file") : unlink("$dir/$file"); + } + rmdir($dir); + } +} diff --git a/tests/Controller/ReserverControllerTest.php b/tests/Controller/ReserverControllerTest.php new file mode 100644 index 0000000..d781063 --- /dev/null +++ b/tests/Controller/ReserverControllerTest.php @@ -0,0 +1,106 @@ +createMock(ProductRepository::class); + $productReserveRepository = $this->createMock(ProductReserveRepository::class); + + // 2. Setup Data + $productId = "1"; + $startStr = "2026-02-07"; + $endStr = "2026-02-14"; + + $product = new Product(); + // We can't set ID easily if it's generated, but the repo find returning it is enough. + + $productRepository->expects($this->once()) + ->method('find') + ->with($productId) + ->willReturn($product); + + $productReserveRepository->expects($this->once()) + ->method('checkAvailability') + ->willReturn(true); + + // 3. Create Request + $request = new Request([], [], [], [], [], [], json_encode([ + 'id' => $productId, + 'start' => $startStr, + 'end' => $endStr + ])); + $request->setMethod('POST'); + $request->headers->set('Content-Type', 'application/json'); + + // 4. Instantiate Controller + $controller = new ReserverController(); + + // 5. Call Method + $response = $controller->productCheck($request, $productReserveRepository, $productRepository); + + // 6. Assertions + $this->assertInstanceOf(JsonResponse::class, $response); + $content = json_decode($response->getContent(), true); + + $this->assertArrayHasKey('dispo', $content); + $this->assertTrue($content['dispo']); + } + + public function testProductCheckNotAvailable() + { + // 1. Mock Repositories + $productRepository = $this->createMock(ProductRepository::class); + $productReserveRepository = $this->createMock(ProductReserveRepository::class); + + // 2. Setup Data + $productId = "1"; + $startStr = "2026-02-07"; + $endStr = "2026-02-14"; + + $product = new Product(); + + $productRepository->expects($this->once()) + ->method('find') + ->with($productId) + ->willReturn($product); + + $productReserveRepository->expects($this->once()) + ->method('checkAvailability') + ->willReturn(false); + + // 3. Create Request + $request = new Request([], [], [], [], [], [], json_encode([ + 'id' => $productId, + 'start' => $startStr, + 'end' => $endStr + ])); + $request->setMethod('POST'); + $request->headers->set('Content-Type', 'application/json'); + + // 4. Instantiate Controller + $controller = new ReserverController(); + + // 5. Call Method + $response = $controller->productCheck($request, $productReserveRepository, $productRepository); + + // 6. Assertions + $this->assertInstanceOf(JsonResponse::class, $response); + $content = json_decode($response->getContent(), true); + + $this->assertArrayHasKey('dispo', $content); + $this->assertFalse($content['dispo']); + } +} diff --git a/tests/Repository/ProductReserveRepositoryTest.php b/tests/Repository/ProductReserveRepositoryTest.php new file mode 100644 index 0000000..4b25bf1 --- /dev/null +++ b/tests/Repository/ProductReserveRepositoryTest.php @@ -0,0 +1,126 @@ +entityManager = $kernel->getContainer() + ->get('doctrine') + ->getManager(); + + $this->repository = $this->entityManager->getRepository(ProductReserve::class); + + // Optional: Purge database or ensure clean state + // For now, we assume a test DB or we just append data. + // Ideally, we should use a separate test DB. + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->entityManager->close(); + $this->entityManager = null; + $this->repository = null; + } + + public function testCheckAvailability() + { + // 1. Create a Product + $product = new Product(); + $product->setRef('TEST-REF-' . uniqid()); + $product->setCategory('TEST-CAT'); + $product->setName('Test Product ' . uniqid()); + $product->setPriceDay(10.0); + $product->setCaution(100.0); + + $this->entityManager->persist($product); + + // 2. Create an existing reservation + // From 2026-02-10 to 2026-02-15 + $existing = new ProductReserve(); + $existing->setProduct($product); + $existing->setStartAt(new \DateTimeImmutable('2026-02-10 10:00:00')); + $existing->setEndAt(new \DateTimeImmutable('2026-02-15 10:00:00')); + + $this->entityManager->persist($existing); + $this->entityManager->flush(); + + // 3. Test Cases + + // Case A: Completely before (Available) + // 2026-02-01 to 2026-02-05 + $reserveA = new ProductReserve(); + $reserveA->setProduct($product); + $reserveA->setStartAt(new \DateTimeImmutable('2026-02-01 10:00:00')); + $reserveA->setEndAt(new \DateTimeImmutable('2026-02-05 10:00:00')); + + $this->assertTrue($this->repository->checkAvailability($reserveA), 'Should be available (before)'); + + // Case B: Completely after (Available) + // 2026-02-16 to 2026-02-20 + $reserveB = new ProductReserve(); + $reserveB->setProduct($product); + $reserveB->setStartAt(new \DateTimeImmutable('2026-02-16 10:00:00')); + $reserveB->setEndAt(new \DateTimeImmutable('2026-02-20 10:00:00')); + + $this->assertTrue($this->repository->checkAvailability($reserveB), 'Should be available (after)'); + + // Case C: Overlapping (exact match) (Not Available) + // 2026-02-10 to 2026-02-15 + $reserveC = new ProductReserve(); + $reserveC->setProduct($product); + $reserveC->setStartAt(new \DateTimeImmutable('2026-02-10 10:00:00')); + $reserveC->setEndAt(new \DateTimeImmutable('2026-02-15 10:00:00')); + + $this->assertFalse($this->repository->checkAvailability($reserveC), 'Should NOT be available (exact match)'); + + // Case D: Overlapping (partial start) (Not Available) + // 2026-02-09 to 2026-02-11 + $reserveD = new ProductReserve(); + $reserveD->setProduct($product); + $reserveD->setStartAt(new \DateTimeImmutable('2026-02-09 10:00:00')); + $reserveD->setEndAt(new \DateTimeImmutable('2026-02-11 10:00:00')); + + $this->assertFalse($this->repository->checkAvailability($reserveD), 'Should NOT be available (overlap start)'); + + // Case E: Overlapping (partial end) (Not Available) + // 2026-02-14 to 2026-02-16 + $reserveE = new ProductReserve(); + $reserveE->setProduct($product); + $reserveE->setStartAt(new \DateTimeImmutable('2026-02-14 10:00:00')); + $reserveE->setEndAt(new \DateTimeImmutable('2026-02-16 10:00:00')); + + $this->assertFalse($this->repository->checkAvailability($reserveE), 'Should NOT be available (overlap end)'); + + // Case F: Inside (Not Available) + // 2026-02-11 to 2026-02-14 + $reserveF = new ProductReserve(); + $reserveF->setProduct($product); + $reserveF->setStartAt(new \DateTimeImmutable('2026-02-11 10:00:00')); + $reserveF->setEndAt(new \DateTimeImmutable('2026-02-14 10:00:00')); + + $this->assertFalse($this->repository->checkAvailability($reserveF), 'Should NOT be available (inside)'); + + // Case G: Encompassing (Not Available) + // 2026-02-09 to 2026-02-16 + $reserveG = new ProductReserve(); + $reserveG->setProduct($product); + $reserveG->setStartAt(new \DateTimeImmutable('2026-02-09 10:00:00')); + $reserveG->setEndAt(new \DateTimeImmutable('2026-02-16 10:00:00')); + + $this->assertFalse($this->repository->checkAvailability($reserveG), 'Should NOT be available (encompassing)'); + } +}