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,56 @@
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class CspReportControllerTest extends WebTestCase
{
public function testInvalidPayloadReturnsBadRequest(): void
{
$client = static::createClient();
$client->request('POST', '/my-csp-report', [], [], [
'CONTENT_TYPE' => 'application/json',
], '');
self::assertResponseStatusCodeSame(400);
}
public function testBrowserExtensionViolationIsIgnored(): void
{
$client = static::createClient();
$payload = json_encode([
'csp-report' => [
'source-file' => 'chrome-extension://abc',
'blocked-uri' => 'inline',
'document-uri' => 'https://e-cosplay.fr/',
'violated-directive' => 'script-src',
],
]);
$client->request('POST', '/my-csp-report', [], [], [
'CONTENT_TYPE' => 'application/json',
], $payload);
self::assertResponseStatusCodeSame(204);
}
public function testRealViolationIsProcessed(): void
{
$client = static::createClient();
$payload = json_encode([
'csp-report' => [
'source-file' => 'https://evil.com/script.js',
'blocked-uri' => 'https://evil.com',
'document-uri' => 'https://e-cosplay.fr/page',
'violated-directive' => 'script-src',
],
]);
$client->request('POST', '/my-csp-report', [], [], [
'CONTENT_TYPE' => 'application/json',
], $payload);
self::assertResponseStatusCodeSame(204);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class EmailTrackingControllerTest extends WebTestCase
{
public function testTrackReturnsImageResponse(): void
{
$client = static::createClient();
$client->request('GET', '/track/nonexistent-id/logo.jpg');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'image/jpeg');
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class HomeControllerTest extends WebTestCase
{
public function testIndexReturnsSuccess(): void
{
$client = static::createClient();
$client->request('GET', '/');
self::assertResponseIsSuccessful();
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class RedirectControllerTest extends WebTestCase
{
public function testNoUrlRedirectsToHome(): void
{
$client = static::createClient();
$client->request('GET', '/external-redirect');
self::assertResponseRedirects('/');
}
public function testWithUrlRendersWarningPage(): void
{
$client = static::createClient();
$client->request('GET', '/external-redirect?redirUrl=https://example.com');
self::assertResponseIsSuccessful();
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class RegistrationControllerTest extends WebTestCase
{
public function testRegistrationPageReturnsSuccess(): void
{
$client = static::createClient();
$client->request('GET', '/inscription');
self::assertResponseIsSuccessful();
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class RobotsControllerTest extends WebTestCase
{
public function testRobotsReturnsPlainText(): void
{
$client = static::createClient();
$client->request('GET', '/robots.txt');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'text/plain');
}
public function testRobotsContainsSitemap(): void
{
$client = static::createClient();
$client->request('GET', '/robots.txt');
self::assertStringContainsString('Sitemap:', $client->getResponse()->getContent());
}
public function testRobotsContainsDisallowRules(): void
{
$client = static::createClient();
$client->request('GET', '/robots.txt');
$content = $client->getResponse()->getContent();
self::assertStringContainsString('Disallow: /external-redirect', $content);
self::assertStringContainsString('Disallow: /my-csp-report', $content);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class SearchControllerTest extends WebTestCase
{
public function testSearchPageReturnsSuccess(): void
{
$client = static::createClient();
$client->request('GET', '/search');
self::assertResponseIsSuccessful();
}
public function testSearchWithQueryParam(): void
{
$client = static::createClient();
$client->request('GET', '/search?q=cosplay');
self::assertResponseIsSuccessful();
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class SecurityControllerTest extends WebTestCase
{
public function testLoginPageReturnsSuccess(): void
{
$client = static::createClient();
$client->request('GET', '/connexion');
self::assertResponseIsSuccessful();
}
public function testLogoutThrowsLogicException(): void
{
$this->expectException(\LogicException::class);
$controller = new \App\Controller\SecurityController();
$controller->logout();
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class SitemapControllerTest extends WebTestCase
{
public function testSitemapIndexReturnsXml(): void
{
$client = static::createClient();
$client->request('GET', '/sitemap.xml');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'text/xml');
}
public function testSitemapMainReturnsXml(): void
{
$client = static::createClient();
$client->request('GET', '/sitemap-main.xml');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'text/xml');
}
public function testSitemapEventsReturnsXml(): void
{
$client = static::createClient();
$client->request('GET', '/sitemap-events-1.xml');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'text/xml');
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class UnsubscribeControllerTest extends WebTestCase
{
public function testInvalidTokenReturns404(): void
{
$client = static::createClient();
$client->request('GET', '/unsubscribe/invalid-base64');
self::assertResponseStatusCodeSame(404);
}
public function testValidTokenShowsUnsubscribePage(): void
{
$client = static::createClient();
$token = base64_encode('user@example.com');
$client->request('GET', '/unsubscribe/'.$token);
self::assertResponseIsSuccessful();
}
public function testPostConfirmsUnsubscribe(): void
{
$client = static::createClient();
$token = base64_encode('user@example.com');
$client->request('POST', '/unsubscribe/'.$token);
self::assertResponseIsSuccessful();
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Tests\Entity;
use App\Entity\EmailTracking;
use PHPUnit\Framework\TestCase;
class EmailTrackingTest extends TestCase
{
public function testConstructorSetsFields(): void
{
$tracking = new EmailTracking('msg-123', 'user@example.com', 'Hello');
self::assertSame('msg-123', $tracking->getMessageId());
self::assertSame('user@example.com', $tracking->getRecipient());
self::assertSame('Hello', $tracking->getSubject());
self::assertSame('sent', $tracking->getState());
self::assertInstanceOf(\DateTimeImmutable::class, $tracking->getSentAt());
self::assertNull($tracking->getOpenedAt());
self::assertNull($tracking->getId());
}
public function testMarkAsOpenedTransitionsState(): void
{
$tracking = new EmailTracking('msg-123', 'user@example.com', 'Hello');
$tracking->markAsOpened();
self::assertSame('opened', $tracking->getState());
self::assertInstanceOf(\DateTimeImmutable::class, $tracking->getOpenedAt());
}
public function testMarkAsOpenedDoesNotTransitionTwice(): void
{
$tracking = new EmailTracking('msg-123', 'user@example.com', 'Hello');
$tracking->markAsOpened();
$firstOpenedAt = $tracking->getOpenedAt();
$tracking->markAsOpened();
self::assertSame($firstOpenedAt, $tracking->getOpenedAt());
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Tests\Entity;
use App\Entity\MessengerLog;
use PHPUnit\Framework\TestCase;
class MessengerLogTest extends TestCase
{
public function testConstructorSetsFields(): void
{
$log = new MessengerLog(
messageClass: 'App\\Message\\TestMessage',
messageBody: 'serialized-body',
errorMessage: 'Something failed',
stackTrace: '#0 trace',
transportName: 'async',
retryCount: 2,
);
self::assertSame('App\\Message\\TestMessage', $log->getMessageClass());
self::assertSame('serialized-body', $log->getMessageBody());
self::assertSame('failed', $log->getStatus());
self::assertSame('Something failed', $log->getErrorMessage());
self::assertSame('#0 trace', $log->getStackTrace());
self::assertSame('async', $log->getTransportName());
self::assertSame(2, $log->getRetryCount());
self::assertInstanceOf(\DateTimeImmutable::class, $log->getCreatedAt());
self::assertInstanceOf(\DateTimeImmutable::class, $log->getFailedAt());
self::assertNull($log->getId());
}
public function testMarkAsResolved(): void
{
$log = new MessengerLog('Class', null, 'error');
$log->markAsResolved();
self::assertSame('resolved', $log->getStatus());
}
public function testSetStatus(): void
{
$log = new MessengerLog('Class', null, 'error');
$result = $log->setStatus('retrying');
self::assertSame('retrying', $log->getStatus());
self::assertSame($log, $result);
}
}

66
tests/Entity/UserTest.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
namespace App\Tests\Entity;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
class UserTest extends TestCase
{
public function testNewUserHasCreatedAt(): void
{
$user = new User();
self::assertInstanceOf(\DateTimeImmutable::class, $user->getCreatedAt());
}
public function testGetRolesAlwaysIncludesRoleUser(): void
{
$user = new User();
self::assertContains('ROLE_USER', $user->getRoles());
}
public function testSetRolesDeduplicatesRoleUser(): void
{
$user = new User();
$user->setRoles(['ROLE_ADMIN', 'ROLE_USER']);
$roles = $user->getRoles();
self::assertCount(2, $roles);
self::assertContains('ROLE_ADMIN', $roles);
self::assertContains('ROLE_USER', $roles);
}
public function testGetUserIdentifierReturnsEmail(): void
{
$user = new User();
$user->setEmail('test@example.com');
self::assertSame('test@example.com', $user->getUserIdentifier());
}
public function testFluentSetters(): void
{
$user = new User();
$result = $user->setEmail('a@b.com')
->setFirstName('John')
->setLastName('Doe')
->setPassword('hashed');
self::assertSame($user, $result);
self::assertSame('a@b.com', $user->getEmail());
self::assertSame('John', $user->getFirstName());
self::assertSame('Doe', $user->getLastName());
self::assertSame('hashed', $user->getPassword());
}
public function testEraseCredentialsDoesNotThrow(): void
{
$user = new User();
$user->eraseCredentials();
self::assertNull($user->getId());
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Tests\Enum;
use App\Enum\CacheKey;
use PHPUnit\Framework\TestCase;
class CacheKeyTest extends TestCase
{
public function testResolveWithParameters(): void
{
self::assertSame('user_profile_42', CacheKey::USER_PROFILE->resolve(42));
self::assertSame('event_detail_10', CacheKey::EVENT_DETAIL->resolve(10));
self::assertSame('search_symfony', CacheKey::SEARCH_RESULTS->resolve('symfony'));
}
public function testResolveWithoutParameters(): void
{
self::assertSame('event_list', CacheKey::EVENT_LIST->resolve());
self::assertSame('home_page', CacheKey::HOME_PAGE->resolve());
}
public function testTtlReturnsPositiveIntegers(): void
{
foreach (CacheKey::cases() as $case) {
self::assertGreaterThan(0, $case->ttl(), "TTL for {$case->name} should be positive");
}
}
public function testSpecificTtlValues(): void
{
self::assertSame(1800, CacheKey::USER_PROFILE->ttl());
self::assertSame(300, CacheKey::EVENT_LIST->ttl());
self::assertSame(60, CacheKey::EVENT_TICKETS->ttl());
self::assertSame(3600, CacheKey::SITEMAP_MAIN->ttl());
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Tests\EventSubscriber;
use App\EventSubscriber\MessengerFailureSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
class MessengerFailureSubscriberTest extends TestCase
{
public function testGetSubscribedEvents(): void
{
$events = MessengerFailureSubscriber::getSubscribedEvents();
self::assertArrayHasKey(WorkerMessageFailedEvent::class, $events);
self::assertSame('onMessageFailed', $events[WorkerMessageFailedEvent::class]);
}
public function testOnMessageFailedPersistsLogAndSendsEmail(): void
{
$em = $this->createMock(EntityManagerInterface::class);
$mailer = $this->createMock(MailerInterface::class);
$em->expects(self::once())->method('persist');
$em->expects(self::once())->method('flush');
$mailer->expects(self::once())->method('send');
$subscriber = new MessengerFailureSubscriber($em, $mailer);
$message = new \stdClass();
$envelope = new Envelope($message);
$exception = new \RuntimeException('Test failure');
$event = new WorkerMessageFailedEvent($envelope, 'async', $exception);
$subscriber->onMessageFailed($event);
}
public function testOnMessageFailedHandlesMailerException(): void
{
$em = $this->createMock(EntityManagerInterface::class);
$mailer = $this->createMock(MailerInterface::class);
$em->expects(self::once())->method('persist');
$em->expects(self::once())->method('flush');
$mailer->method('send')->willThrowException(new \RuntimeException('mail failed'));
$subscriber = new MessengerFailureSubscriber($em, $mailer);
$message = new \stdClass();
$envelope = new Envelope($message);
$exception = new \RuntimeException('Test failure');
$event = new WorkerMessageFailedEvent($envelope, 'async', $exception);
$subscriber->onMessageFailed($event);
self::assertTrue(true);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Tests\Message;
use App\Message\MeilisearchMessage;
use PHPUnit\Framework\TestCase;
class MeilisearchMessageTest extends TestCase
{
public function testConstructorSetsProperties(): void
{
$message = new MeilisearchMessage('addDocuments', 'events', ['documents' => [['id' => 1]]]);
self::assertSame('addDocuments', $message->action);
self::assertSame('events', $message->index);
self::assertSame(['documents' => [['id' => 1]]], $message->payload);
}
public function testDefaultPayloadIsEmpty(): void
{
$message = new MeilisearchMessage('deleteIndex', 'events');
self::assertSame([], $message->payload);
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Tests\MessageHandler;
use App\Message\MeilisearchMessage;
use App\MessageHandler\MeilisearchMessageHandler;
use App\Service\MeilisearchService;
use PHPUnit\Framework\TestCase;
class MeilisearchMessageHandlerTest extends TestCase
{
private MeilisearchService $meilisearch;
private MeilisearchMessageHandler $handler;
protected function setUp(): void
{
$this->meilisearch = $this->createMock(MeilisearchService::class);
$this->handler = new MeilisearchMessageHandler($this->meilisearch);
}
public function testHandleCreateIndex(): void
{
$this->meilisearch->expects(self::once())
->method('createIndex')
->with('events', 'uid');
($this->handler)(new MeilisearchMessage('createIndex', 'events', ['primaryKey' => 'uid']));
}
public function testHandleDeleteIndex(): void
{
$this->meilisearch->expects(self::once())
->method('deleteIndex')
->with('events');
($this->handler)(new MeilisearchMessage('deleteIndex', 'events'));
}
public function testHandleAddDocuments(): void
{
$docs = [['id' => 1]];
$this->meilisearch->expects(self::once())
->method('addDocuments')
->with('events', $docs);
($this->handler)(new MeilisearchMessage('addDocuments', 'events', ['documents' => $docs]));
}
public function testHandleUpdateDocuments(): void
{
$docs = [['id' => 1, 'title' => 'Updated']];
$this->meilisearch->expects(self::once())
->method('updateDocuments')
->with('events', $docs);
($this->handler)(new MeilisearchMessage('updateDocuments', 'events', ['documents' => $docs]));
}
public function testHandleDeleteDocument(): void
{
$this->meilisearch->expects(self::once())
->method('deleteDocument')
->with('events', 42);
($this->handler)(new MeilisearchMessage('deleteDocument', 'events', ['documentId' => 42]));
}
public function testHandleDeleteDocuments(): void
{
$this->meilisearch->expects(self::once())
->method('deleteDocuments')
->with('events', [1, 2, 3]);
($this->handler)(new MeilisearchMessage('deleteDocuments', 'events', ['ids' => [1, 2, 3]]));
}
public function testHandleUpdateSettings(): void
{
$settings = ['searchableAttributes' => ['title']];
$this->meilisearch->expects(self::once())
->method('updateSettings')
->with('events', $settings);
($this->handler)(new MeilisearchMessage('updateSettings', 'events', ['settings' => $settings]));
}
public function testUnknownActionThrowsException(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Unknown action: invalid');
($this->handler)(new MeilisearchMessage('invalid', 'events'));
}
}

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);
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Tests\Twig;
use App\Twig\ViteAssetExtension;
use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
class ViteAssetExtensionTest extends TestCase
{
private CacheItemPoolInterface $cache;
protected function setUp(): void
{
$this->cache = $this->createMock(CacheItemPoolInterface::class);
$_ENV['VITE_LOAD'] = '1';
}
private function createExtension(string $manifestPath = '/tmp/nonexistent'): ViteAssetExtension
{
return new ViteAssetExtension($manifestPath, $this->cache);
}
public function testGetFunctionsReturnsExpectedNames(): void
{
$extension = $this->createExtension();
$functions = $extension->getFunctions();
$names = array_map(fn ($f) => $f->getName(), $functions);
self::assertContains('vite_asset', $names);
self::assertContains('isMobile', $names);
self::assertContains('vite_favicons', $names);
}
public function testAssetDevReturnsScriptTags(): void
{
$_ENV['VITE_LOAD'] = '0';
$extension = $this->createExtension();
$html = $extension->asset('app.js', []);
self::assertStringContainsString('localhost:5173', $html);
self::assertStringContainsString('app.js', $html);
}
public function testAssetProdUsesManifest(): void
{
$manifest = [
'app.js' => [
'file' => 'assets/app.abc123.js',
'css' => ['assets/app.def456.css'],
],
];
$item = $this->createMock(CacheItemInterface::class);
$item->method('isHit')->willReturn(true);
$item->method('get')->willReturn($manifest);
$this->cache->method('getItem')->willReturn($item);
$extension = $this->createExtension();
$html = $extension->assetProd('app.js');
self::assertStringContainsString('assets/app.abc123.js', $html);
self::assertStringContainsString('assets/app.def456.css', $html);
}
public function testAssetProdHandlesMissingManifest(): void
{
$item = $this->createMock(CacheItemInterface::class);
$item->method('isHit')->willReturn(false);
$this->cache->method('getItem')->willReturn($item);
$extension = $this->createExtension('/tmp/nonexistent_manifest.json');
$html = $extension->assetProd('missing.js');
self::assertStringContainsString('script', $html);
}
public function testFaviconsDevReturnsFaviconLink(): void
{
$_ENV['VITE_LOAD'] = '0';
$extension = $this->createExtension();
$html = $extension->favicons();
self::assertStringContainsString('favicon.ico', $html);
}
}