feat(ReserverController): Ajoute vérification de disponibilité produit.
🛠️ refactor(BackupCommand): Utilise DatabaseDumper et ZipArchiver.
 feat(GitSyncLogCommand): Utilise Gemini pour messages plus clairs.
 feat(GenerateVideoThumbsCommand): Utilise VideoThumbnailer service.
 feat(AppWarmupImagesCommand): Utilise StorageInterface pour warmup.
🔒️ security(nelmio_security): Renforce la sécurité avec des en-têtes.
🔧 chore(caddy): Améliore la configuration de Caddy pour la performance.
🐛 fix(makefile): Corrige les commandes de test.
🧪 chore(.env.test): Supprime la ligne vide à la fin du fichier.
🔧 chore(doctrine): Active native_lazy_objects.
🔧 chore(cache): Ajoute un cache system.
```
This commit is contained in:
Serreau Jovann
2026-01-30 17:58:12 +01:00
parent a6fc8fdf3b
commit 36a51c5a54
34 changed files with 1879 additions and 164 deletions

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Tests\Command;
use App\Command\AppWarmupImagesCommand;
use App\Repository\OptionsRepository;
use App\Repository\ProductRepository;
use Liip\ImagineBundle\Binary\BinaryInterface;
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use Liip\ImagineBundle\Imagine\Data\DataManager;
use Liip\ImagineBundle\Imagine\Filter\FilterManager;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Vich\UploaderBundle\Storage\StorageInterface;
class AppWarmupImagesCommandTest extends TestCase
{
public function testExecute()
{
// Mocks
$productRepo = $this->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);
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Tests\Command;
use App\Command\BackupCommand;
use App\Entity\Backup;
use App\Service\Mailer\Mailer;
use App\Service\System\DatabaseDumper;
use App\Service\System\ZipArchiver;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\HttpKernel\KernelInterface;
class BackupCommandTest extends TestCase
{
private MockObject&KernelInterface $kernel;
private MockObject&EntityManagerInterface $entityManager;
private MockObject&Mailer $mailer;
private MockObject&DatabaseDumper $databaseDumper;
private MockObject&ZipArchiver $zipArchiver;
protected function setUp(): void
{
$this->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);
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Tests\Command;
use App\Command\CleanCommand;
use App\Entity\CustomerTracking;
use App\Entity\SitePerformance;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
class CleanCommandTest extends TestCase
{
private MockObject&EntityManagerInterface $entityManager;
private MockObject&QueryBuilder $qb1;
private MockObject&QueryBuilder $qb2;
private MockObject&Query $query1;
private MockObject&Query $query2;
protected function setUp(): void
{
$this->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);
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace App\Tests\Command;
use App\Command\DeployConfigCommand;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
class DeployConfigCommandTest extends TestCase
{
private MockObject&ParameterBagInterface $parameterBag;
private MockObject&HttpClientInterface $httpClient;
protected function setUp(): void
{
$this->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());
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Tests\Command;
use App\Command\GenerateVideoThumbsCommand;
use App\Service\Media\VideoThumbnailer;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
class GenerateVideoThumbsCommandTest extends TestCase
{
private MockObject&ParameterBagInterface $parameterBag;
private MockObject&VideoThumbnailer $videoThumbnailer;
private string $tempDir;
protected function setUp(): void
{
$this->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);
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Tests\Command;
use App\Command\GitSyncLogCommand;
use App\Service\AI\GeminiClient;
use App\Service\System\GitClient;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
class GitSyncLogCommandTest extends TestCase
{
private MockObject&HttpClientInterface $httpClient;
private MockObject&KernelInterface $kernel;
private MockObject&GitClient $gitClient;
private MockObject&GeminiClient $geminiClient;
private string $tempDir;
protected function setUp(): void
{
$this->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);
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace App\Tests\Command;
use App\Command\MailCommand;
use App\Entity\Contrats;
use App\Entity\Customer;
use App\Entity\Devis;
use App\Service\Mailer\Mailer;
use App\Service\Signature\Client;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\HttpKernel\KernelInterface;
class MailCommandTest extends TestCase
{
private MockObject&KernelInterface $kernel;
private MockObject&Client $client;
private MockObject&Mailer $mailer;
private MockObject&EntityManagerInterface $entityManager;
private MockObject&EntityRepository $devisRepository;
private MockObject&EntityRepository $contratsRepository;
protected function setUp(): void
{
$this->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);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Tests\Command;
use App\Command\MaintenanceCommand;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
class MaintenanceCommandTest extends TestCase
{
private string $tempDir;
protected function setUp(): void
{
$this->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);
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Tests\Command;
use App\Command\PurgeCommand;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
class PurgeCommandTest extends TestCase
{
private MockObject&HttpClientInterface $httpClient;
protected function setUp(): void
{
$this->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());
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Tests\Command;
use App\Command\PurgeTxtCommand;
use App\Entity\Formules;
use App\Entity\Product;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
class PurgeTxtCommandTest extends TestCase
{
private MockObject&EntityManagerInterface $entityManager;
private MockObject&EntityRepository $productRepository;
private MockObject&EntityRepository $formulesRepository;
protected function setUp(): void
{
$this->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('<p>Description <b>HTML</b> &eacute; purger.</p>');
$this->productRepository->method('findAll')->willReturn([$product]);
// 2. Setup Formules Data
$formule = new Formules();
$formule->setDescription('<div>Une autre <br> description.</div>');
$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());
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Tests\Command;
use App\Command\SearchCommand;
use App\Entity\Account;
use App\Entity\Contrats;
use App\Entity\Customer;
use App\Entity\Options;
use App\Entity\Product;
use App\Service\Search\Client;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
class SearchCommandTest extends TestCase
{
private MockObject&EntityManagerInterface $entityManager;
private MockObject&Client $client;
// Repositories
private MockObject&EntityRepository $accountRepo;
private MockObject&EntityRepository $customerRepo;
private MockObject&EntityRepository $productRepo;
private MockObject&EntityRepository $optionsRepo;
private MockObject&EntityRepository $contratsRepo;
protected function setUp(): void
{
$this->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]);
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Tests\Command;
use App\Command\SitemapCommand;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Presta\SitemapBundle\Service\DumperInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\HttpKernel\KernelInterface;
class SitemapCommandTest extends TestCase
{
private MockObject&KernelInterface $kernel;
private MockObject&DumperInterface $dumper;
private string $tempDir;
protected function setUp(): void
{
$this->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);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Tests\Controller;
use App\Controller\ReserverController;
use App\Entity\Product;
use App\Entity\ProductReserve;
use App\Repository\ProductRepository;
use App\Repository\ProductReserveRepository;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
class ReserverControllerTest extends TestCase
{
public function testProductCheckAvailable()
{
// 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();
// 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']);
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Tests\Repository;
use App\Entity\Product;
use App\Entity\ProductReserve;
use App\Repository\ProductReserveRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class ProductReserveRepositoryTest extends KernelTestCase
{
private ?EntityManagerInterface $entityManager;
private ?ProductReserveRepository $repository;
protected function setUp(): void
{
$kernel = self::bootKernel();
$this->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)');
}
}