Add PHPUnit tests with coverage for all src classes

- 21 test files covering controllers, services, entities, enums, messages
- CI: add test job with Xdebug coverage (clover + text)
- SonarQube: configure coverage report path and test sources

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-18 22:50:23 +01:00
parent d13e9b6b80
commit dc3d464b17
24 changed files with 1120 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Tests\Service;
use App\Enum\CacheKey;
use App\Service\CacheService;
use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
class CacheServiceTest extends TestCase
{
private CacheItemPoolInterface $pool;
private CacheService $service;
protected function setUp(): void
{
$this->pool = $this->createMock(CacheItemPoolInterface::class);
$this->service = new CacheService($this->pool);
}
public function testGetReturnsNullOnCacheMiss(): void
{
$item = $this->createMock(CacheItemInterface::class);
$item->method('isHit')->willReturn(false);
$this->pool->method('getItem')->willReturn($item);
self::assertNull($this->service->get(CacheKey::HOME_PAGE));
}
public function testGetReturnsValueOnCacheHit(): void
{
$item = $this->createMock(CacheItemInterface::class);
$item->method('isHit')->willReturn(true);
$item->method('get')->willReturn('cached-data');
$this->pool->method('getItem')->willReturn($item);
self::assertSame('cached-data', $this->service->get(CacheKey::HOME_PAGE));
}
public function testSetStoresValueWithTtl(): void
{
$item = $this->createMock(CacheItemInterface::class);
$item->expects(self::once())->method('set')->with('value');
$item->expects(self::once())->method('expiresAfter')->with(CacheKey::HOME_PAGE->ttl());
$this->pool->method('getItem')->willReturn($item);
$this->pool->expects(self::once())->method('save')->with($item);
$this->service->set(CacheKey::HOME_PAGE, 'value');
}
public function testExistsDelegatesToHasItem(): void
{
$this->pool->expects(self::once())->method('hasItem')->willReturn(true);
self::assertTrue($this->service->exists(CacheKey::HOME_PAGE));
}
public function testDeleteDelegatesToDeleteItem(): void
{
$this->pool->expects(self::once())->method('deleteItem');
$this->service->delete(CacheKey::HOME_PAGE);
}
public function testRememberReturnsCachedValueOnHit(): void
{
$item = $this->createMock(CacheItemInterface::class);
$item->method('isHit')->willReturn(true);
$item->method('get')->willReturn('cached');
$this->pool->method('getItem')->willReturn($item);
$result = $this->service->remember(CacheKey::HOME_PAGE, fn () => 'fresh');
self::assertSame('cached', $result);
}
public function testRememberCallsCallbackOnMiss(): void
{
$item = $this->createMock(CacheItemInterface::class);
$item->method('isHit')->willReturn(false);
$item->expects(self::once())->method('set')->with('fresh');
$item->expects(self::once())->method('expiresAfter')->with(CacheKey::HOME_PAGE->ttl());
$this->pool->method('getItem')->willReturn($item);
$this->pool->expects(self::once())->method('save')->with($item);
$result = $this->service->remember(CacheKey::HOME_PAGE, fn () => 'fresh');
self::assertSame('fresh', $result);
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Tests\Service;
use App\Service\MailerService;
use App\Service\UnsubscribeManager;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class MailerServiceTest extends TestCase
{
private MessageBusInterface $bus;
private UnsubscribeManager $unsubscribeManager;
private EntityManagerInterface $em;
private UrlGeneratorInterface $urlGenerator;
private MailerService $service;
protected function setUp(): void
{
$this->bus = $this->createMock(MessageBusInterface::class);
$this->unsubscribeManager = $this->createMock(UnsubscribeManager::class);
$this->em = $this->createMock(EntityManagerInterface::class);
$this->urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$this->service = new MailerService(
$this->bus,
sys_get_temp_dir(),
'passphrase',
$this->urlGenerator,
$this->unsubscribeManager,
$this->em,
);
}
public function testSendEmailSkipsUnsubscribedRecipient(): void
{
$this->unsubscribeManager->method('isUnsubscribed')->willReturn(true);
$this->bus->expects(self::never())->method('dispatch');
$this->service->sendEmail('user@example.com', 'Subject', '<p>Body</p>');
}
public function testSendEmailDoesNotSkipWhitelistedAddress(): void
{
$this->unsubscribeManager->method('isUnsubscribed')->willReturn(true);
$this->urlGenerator->method('generate')->willReturn('https://example.com/track/abc');
$this->em->expects(self::once())->method('persist');
$this->em->expects(self::once())->method('flush');
$this->bus->expects(self::once())->method('dispatch')->willReturn(new Envelope(new \stdClass()));
$this->service->sendEmail('contact@e-cosplay.fr', 'Subject', '<p>Body</p>');
}
public function testSendEmailDispatchesForNonUnsubscribedUser(): void
{
$this->unsubscribeManager->method('isUnsubscribed')->willReturn(false);
$this->unsubscribeManager->method('generateToken')->willReturn('token123');
$this->urlGenerator->method('generate')->willReturn('https://example.com/url');
$this->em->expects(self::once())->method('persist');
$this->em->expects(self::once())->method('flush');
$this->bus->expects(self::once())->method('dispatch')->willReturn(new Envelope(new \stdClass()));
$this->service->sendEmail('user@example.com', 'Test', '<p>Content</p>');
}
public function testSendEmailWithoutUnsubscribeHeaders(): void
{
$this->urlGenerator->method('generate')->willReturn('https://example.com/url');
$this->em->expects(self::once())->method('persist');
$this->bus->expects(self::once())->method('dispatch')->willReturn(new Envelope(new \stdClass()));
$this->service->sendEmail('user@example.com', 'Test', '<p>Content</p>', withUnsubscribe: false);
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Tests\Service;
use App\Message\MeilisearchMessage;
use App\Service\MeilisearchService;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
class MeilisearchServiceTest extends TestCase
{
private HttpClientInterface $httpClient;
private MessageBusInterface $bus;
private MeilisearchService $service;
protected function setUp(): void
{
$this->httpClient = $this->createMock(HttpClientInterface::class);
$this->bus = $this->createMock(MessageBusInterface::class);
$this->service = new MeilisearchService(
$this->httpClient,
$this->bus,
'http://meilisearch:7700',
'test-key',
);
}
public function testIndexExistsReturnsTrue(): void
{
$response = $this->createMock(ResponseInterface::class);
$response->method('getStatusCode')->willReturn(200);
$this->httpClient->method('request')->willReturn($response);
self::assertTrue($this->service->indexExists('events'));
}
public function testIndexExistsReturnsFalseOnException(): void
{
$this->httpClient->method('request')->willThrowException(new \RuntimeException('fail'));
self::assertFalse($this->service->indexExists('events'));
}
public function testCreateIndexDispatchesMessage(): void
{
$this->bus->expects(self::once())
->method('dispatch')
->with(self::callback(fn (MeilisearchMessage $m) => 'createIndex' === $m->action && 'events' === $m->index))
->willReturn(new Envelope(new \stdClass()));
$this->service->createIndex('events');
}
public function testDeleteIndexDispatchesMessage(): void
{
$this->bus->expects(self::once())
->method('dispatch')
->with(self::callback(fn (MeilisearchMessage $m) => 'deleteIndex' === $m->action))
->willReturn(new Envelope(new \stdClass()));
$this->service->deleteIndex('events');
}
public function testAddDocumentsDispatchesMessage(): void
{
$docs = [['id' => 1, 'title' => 'Test']];
$this->bus->expects(self::once())
->method('dispatch')
->with(self::callback(fn (MeilisearchMessage $m) => 'addDocuments' === $m->action && $m->payload['documents'] === $docs))
->willReturn(new Envelope(new \stdClass()));
$this->service->addDocuments('events', $docs);
}
public function testSearchMakesPostRequest(): void
{
$response = $this->createMock(ResponseInterface::class);
$response->method('getStatusCode')->willReturn(200);
$response->method('toArray')->willReturn(['hits' => []]);
$this->httpClient->method('request')
->with('POST', self::stringContains('/indexes/events/search'), self::anything())
->willReturn($response);
$result = $this->service->search('events', 'test');
self::assertArrayHasKey('hits', $result);
}
public function testRequestReturnsEmptyArrayOn204(): void
{
$response = $this->createMock(ResponseInterface::class);
$response->method('getStatusCode')->willReturn(204);
$this->httpClient->method('request')->willReturn($response);
$result = $this->service->request('DELETE', '/indexes/events');
self::assertSame([], $result);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Tests\Service;
use App\Service\UnsubscribeManager;
use PHPUnit\Framework\TestCase;
class UnsubscribeManagerTest extends TestCase
{
private string $tempDir;
private UnsubscribeManager $manager;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir().'/unsubscribe_test_'.uniqid();
mkdir($this->tempDir.'/var', 0755, true);
$this->manager = new UnsubscribeManager($this->tempDir, 'test-secret');
}
protected function tearDown(): void
{
$file = $this->tempDir.'/var/unsubscribed.json';
if (file_exists($file)) {
unlink($file);
}
if (is_dir($this->tempDir.'/var')) {
rmdir($this->tempDir.'/var');
}
if (is_dir($this->tempDir)) {
rmdir($this->tempDir);
}
}
public function testGenerateTokenIsDeterministic(): void
{
$token1 = $this->manager->generateToken('user@example.com');
$token2 = $this->manager->generateToken('user@example.com');
self::assertSame($token1, $token2);
}
public function testGenerateTokenNormalizesEmail(): void
{
$token1 = $this->manager->generateToken('User@Example.com');
$token2 = $this->manager->generateToken(' user@example.com ');
self::assertSame($token1, $token2);
}
public function testIsValidTokenReturnsTrueForMatchingToken(): void
{
$token = $this->manager->generateToken('user@example.com');
self::assertTrue($this->manager->isValidToken('user@example.com', $token));
}
public function testIsValidTokenReturnsFalseForWrongToken(): void
{
self::assertFalse($this->manager->isValidToken('user@example.com', 'wrong-token'));
}
public function testIsUnsubscribedReturnsFalseByDefault(): void
{
self::assertFalse($this->manager->isUnsubscribed('user@example.com'));
}
public function testUnsubscribeAndIsUnsubscribed(): void
{
$this->manager->unsubscribe('user@example.com');
self::assertTrue($this->manager->isUnsubscribed('user@example.com'));
}
public function testUnsubscribeIsIdempotent(): void
{
$this->manager->unsubscribe('user@example.com');
$this->manager->unsubscribe('user@example.com');
$data = json_decode(file_get_contents($this->tempDir.'/var/unsubscribed.json'), true);
self::assertCount(1, $data);
}
}