From dc3d464b1785870048160033bdc4f7ecdbbfc051 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Wed, 18 Mar 2026 22:50:23 +0100 Subject: [PATCH] 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) --- .gitea/workflows/ci.yml | 19 ++++ sonar-project.properties | 3 + tests/Controller/CspReportControllerTest.php | 56 ++++++++++ .../EmailTrackingControllerTest.php | 17 +++ tests/Controller/HomeControllerTest.php | 16 +++ tests/Controller/RedirectControllerTest.php | 24 +++++ .../Controller/RegistrationControllerTest.php | 16 +++ tests/Controller/RobotsControllerTest.php | 35 ++++++ tests/Controller/SearchControllerTest.php | 24 +++++ tests/Controller/SecurityControllerTest.php | 24 +++++ tests/Controller/SitemapControllerTest.php | 35 ++++++ .../Controller/UnsubscribeControllerTest.php | 34 ++++++ tests/Entity/EmailTrackingTest.php | 42 ++++++++ tests/Entity/MessengerLogTest.php | 49 +++++++++ tests/Entity/UserTest.php | 66 ++++++++++++ tests/Enum/CacheKeyTest.php | 37 +++++++ .../MessengerFailureSubscriberTest.php | 62 +++++++++++ tests/Message/MeilisearchMessageTest.php | 25 +++++ .../MeilisearchMessageHandlerTest.php | 94 ++++++++++++++++ tests/Service/CacheServiceTest.php | 91 ++++++++++++++++ tests/Service/MailerServiceTest.php | 77 +++++++++++++ tests/Service/MeilisearchServiceTest.php | 102 ++++++++++++++++++ tests/Service/UnsubscribeManagerTest.php | 82 ++++++++++++++ tests/Twig/ViteAssetExtensionTest.php | 90 ++++++++++++++++ 24 files changed, 1120 insertions(+) create mode 100644 tests/Controller/CspReportControllerTest.php create mode 100644 tests/Controller/EmailTrackingControllerTest.php create mode 100644 tests/Controller/HomeControllerTest.php create mode 100644 tests/Controller/RedirectControllerTest.php create mode 100644 tests/Controller/RegistrationControllerTest.php create mode 100644 tests/Controller/RobotsControllerTest.php create mode 100644 tests/Controller/SearchControllerTest.php create mode 100644 tests/Controller/SecurityControllerTest.php create mode 100644 tests/Controller/SitemapControllerTest.php create mode 100644 tests/Controller/UnsubscribeControllerTest.php create mode 100644 tests/Entity/EmailTrackingTest.php create mode 100644 tests/Entity/MessengerLogTest.php create mode 100644 tests/Entity/UserTest.php create mode 100644 tests/Enum/CacheKeyTest.php create mode 100644 tests/EventSubscriber/MessengerFailureSubscriberTest.php create mode 100644 tests/Message/MeilisearchMessageTest.php create mode 100644 tests/MessageHandler/MeilisearchMessageHandlerTest.php create mode 100644 tests/Service/CacheServiceTest.php create mode 100644 tests/Service/MailerServiceTest.php create mode 100644 tests/Service/MeilisearchServiceTest.php create mode 100644 tests/Service/UnsubscribeManagerTest.php create mode 100644 tests/Twig/ViteAssetExtensionTest.php diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1cd4faa..7c71539 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -93,6 +93,25 @@ jobs: - name: Security audit run: composer audit + test: + runs_on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: intl, pdo_pgsql, zip, gd, redis, imagick + coverage: xdebug + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run PHPUnit with coverage + run: vendor/bin/phpunit --testdox --coverage-clover coverage.xml --coverage-text + sonarqube: runs_on: ubuntu-latest steps: diff --git a/sonar-project.properties b/sonar-project.properties index e6505d1..9e8884e 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -4,3 +4,6 @@ sonar.sources=src,assets,templates sonar.exclusions=vendor/**,node_modules/**,public/build/**,var/**,migrations/** sonar.php.version=8.4 sonar.sourceEncoding=UTF-8 +sonar.php.coverage.reportPaths=coverage.xml +sonar.tests=tests +sonar.test.inclusions=tests/**/*.php diff --git a/tests/Controller/CspReportControllerTest.php b/tests/Controller/CspReportControllerTest.php new file mode 100644 index 0000000..29cda9e --- /dev/null +++ b/tests/Controller/CspReportControllerTest.php @@ -0,0 +1,56 @@ +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); + } +} diff --git a/tests/Controller/EmailTrackingControllerTest.php b/tests/Controller/EmailTrackingControllerTest.php new file mode 100644 index 0000000..f51b9af --- /dev/null +++ b/tests/Controller/EmailTrackingControllerTest.php @@ -0,0 +1,17 @@ +request('GET', '/track/nonexistent-id/logo.jpg'); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'image/jpeg'); + } +} diff --git a/tests/Controller/HomeControllerTest.php b/tests/Controller/HomeControllerTest.php new file mode 100644 index 0000000..eb3cacd --- /dev/null +++ b/tests/Controller/HomeControllerTest.php @@ -0,0 +1,16 @@ +request('GET', '/'); + + self::assertResponseIsSuccessful(); + } +} diff --git a/tests/Controller/RedirectControllerTest.php b/tests/Controller/RedirectControllerTest.php new file mode 100644 index 0000000..5730f79 --- /dev/null +++ b/tests/Controller/RedirectControllerTest.php @@ -0,0 +1,24 @@ +request('GET', '/external-redirect'); + + self::assertResponseRedirects('/'); + } + + public function testWithUrlRendersWarningPage(): void + { + $client = static::createClient(); + $client->request('GET', '/external-redirect?redirUrl=https://example.com'); + + self::assertResponseIsSuccessful(); + } +} diff --git a/tests/Controller/RegistrationControllerTest.php b/tests/Controller/RegistrationControllerTest.php new file mode 100644 index 0000000..0a23381 --- /dev/null +++ b/tests/Controller/RegistrationControllerTest.php @@ -0,0 +1,16 @@ +request('GET', '/inscription'); + + self::assertResponseIsSuccessful(); + } +} diff --git a/tests/Controller/RobotsControllerTest.php b/tests/Controller/RobotsControllerTest.php new file mode 100644 index 0000000..9069f1c --- /dev/null +++ b/tests/Controller/RobotsControllerTest.php @@ -0,0 +1,35 @@ +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); + } +} diff --git a/tests/Controller/SearchControllerTest.php b/tests/Controller/SearchControllerTest.php new file mode 100644 index 0000000..d21c88e --- /dev/null +++ b/tests/Controller/SearchControllerTest.php @@ -0,0 +1,24 @@ +request('GET', '/search'); + + self::assertResponseIsSuccessful(); + } + + public function testSearchWithQueryParam(): void + { + $client = static::createClient(); + $client->request('GET', '/search?q=cosplay'); + + self::assertResponseIsSuccessful(); + } +} diff --git a/tests/Controller/SecurityControllerTest.php b/tests/Controller/SecurityControllerTest.php new file mode 100644 index 0000000..09dfd05 --- /dev/null +++ b/tests/Controller/SecurityControllerTest.php @@ -0,0 +1,24 @@ +request('GET', '/connexion'); + + self::assertResponseIsSuccessful(); + } + + public function testLogoutThrowsLogicException(): void + { + $this->expectException(\LogicException::class); + + $controller = new \App\Controller\SecurityController(); + $controller->logout(); + } +} diff --git a/tests/Controller/SitemapControllerTest.php b/tests/Controller/SitemapControllerTest.php new file mode 100644 index 0000000..5df26b7 --- /dev/null +++ b/tests/Controller/SitemapControllerTest.php @@ -0,0 +1,35 @@ +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'); + } +} diff --git a/tests/Controller/UnsubscribeControllerTest.php b/tests/Controller/UnsubscribeControllerTest.php new file mode 100644 index 0000000..ca4e7c6 --- /dev/null +++ b/tests/Controller/UnsubscribeControllerTest.php @@ -0,0 +1,34 @@ +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(); + } +} diff --git a/tests/Entity/EmailTrackingTest.php b/tests/Entity/EmailTrackingTest.php new file mode 100644 index 0000000..e2c619a --- /dev/null +++ b/tests/Entity/EmailTrackingTest.php @@ -0,0 +1,42 @@ +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()); + } +} diff --git a/tests/Entity/MessengerLogTest.php b/tests/Entity/MessengerLogTest.php new file mode 100644 index 0000000..40a7c1b --- /dev/null +++ b/tests/Entity/MessengerLogTest.php @@ -0,0 +1,49 @@ +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); + } +} diff --git a/tests/Entity/UserTest.php b/tests/Entity/UserTest.php new file mode 100644 index 0000000..7114edb --- /dev/null +++ b/tests/Entity/UserTest.php @@ -0,0 +1,66 @@ +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()); + } +} diff --git a/tests/Enum/CacheKeyTest.php b/tests/Enum/CacheKeyTest.php new file mode 100644 index 0000000..5938473 --- /dev/null +++ b/tests/Enum/CacheKeyTest.php @@ -0,0 +1,37 @@ +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()); + } +} diff --git a/tests/EventSubscriber/MessengerFailureSubscriberTest.php b/tests/EventSubscriber/MessengerFailureSubscriberTest.php new file mode 100644 index 0000000..2704bea --- /dev/null +++ b/tests/EventSubscriber/MessengerFailureSubscriberTest.php @@ -0,0 +1,62 @@ +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); + } +} diff --git a/tests/Message/MeilisearchMessageTest.php b/tests/Message/MeilisearchMessageTest.php new file mode 100644 index 0000000..792d199 --- /dev/null +++ b/tests/Message/MeilisearchMessageTest.php @@ -0,0 +1,25 @@ + [['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); + } +} diff --git a/tests/MessageHandler/MeilisearchMessageHandlerTest.php b/tests/MessageHandler/MeilisearchMessageHandlerTest.php new file mode 100644 index 0000000..59eb00e --- /dev/null +++ b/tests/MessageHandler/MeilisearchMessageHandlerTest.php @@ -0,0 +1,94 @@ +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')); + } +} diff --git a/tests/Service/CacheServiceTest.php b/tests/Service/CacheServiceTest.php new file mode 100644 index 0000000..7b0933b --- /dev/null +++ b/tests/Service/CacheServiceTest.php @@ -0,0 +1,91 @@ +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); + } +} diff --git a/tests/Service/MailerServiceTest.php b/tests/Service/MailerServiceTest.php new file mode 100644 index 0000000..5f4145d --- /dev/null +++ b/tests/Service/MailerServiceTest.php @@ -0,0 +1,77 @@ +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', '

Body

'); + } + + 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', '

Body

'); + } + + 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', '

Content

'); + } + + 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', '

Content

', withUnsubscribe: false); + } +} diff --git a/tests/Service/MeilisearchServiceTest.php b/tests/Service/MeilisearchServiceTest.php new file mode 100644 index 0000000..5845d80 --- /dev/null +++ b/tests/Service/MeilisearchServiceTest.php @@ -0,0 +1,102 @@ +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); + } +} diff --git a/tests/Service/UnsubscribeManagerTest.php b/tests/Service/UnsubscribeManagerTest.php new file mode 100644 index 0000000..faa3267 --- /dev/null +++ b/tests/Service/UnsubscribeManagerTest.php @@ -0,0 +1,82 @@ +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); + } +} diff --git a/tests/Twig/ViteAssetExtensionTest.php b/tests/Twig/ViteAssetExtensionTest.php new file mode 100644 index 0000000..ed89a67 --- /dev/null +++ b/tests/Twig/ViteAssetExtensionTest.php @@ -0,0 +1,90 @@ +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); + } +}