diff --git a/.coderabbit.yaml b/.coderabbit.yaml index edaddc74..0577bc26 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -9,12 +9,11 @@ reviews: high_level_summary_in_walkthrough: false changed_files_summary: false poem: false + finishing_touches: + docstrings: + enabled: false auto_review: enabled: true base_branches: - ".*" drafts: false - -checks: - docstring_coverage: - enabled: false diff --git a/src/Statistics/Controller/MessageOpenTrackController.php b/src/Statistics/Controller/MessageOpenTrackController.php new file mode 100644 index 00000000..ccd3a675 --- /dev/null +++ b/src/Statistics/Controller/MessageOpenTrackController.php @@ -0,0 +1,104 @@ +returnPixelResponse(); + } + + $metadata = [ + 'HTTP_USER_AGENT' => $request->server->get('HTTP_USER_AGENT'), + 'HTTP_REFERER' => $request->server->get('HTTP_REFERER'), + 'client_ip' => $request->getClientIp(), + ]; + + try { + $this->userMessageService->trackUserMessageView($uid, $messageId, $metadata); + $this->entityManager->flush(); + } catch (Throwable $e) { + $this->logger->error( + 'Failed to track user message view', + [ + 'exception' => $e, + 'message_id' => $messageId, + ] + ); + } + + return $this->returnPixelResponse(); + } + + private function returnPixelResponse(): Response + { + return new Response( + content: base64_decode('R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='), + status: 200, + headers: [ + 'Content-Type' => 'image/gif', + 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma' => 'no-cache', + 'Expires' => '0', + ] + ); + } +} diff --git a/tests/Integration/Statistics/Controller/MessageOpenTrackControllerTest.php b/tests/Integration/Statistics/Controller/MessageOpenTrackControllerTest.php new file mode 100644 index 00000000..011aded2 --- /dev/null +++ b/tests/Integration/Statistics/Controller/MessageOpenTrackControllerTest.php @@ -0,0 +1,74 @@ +get(MessageOpenTrackController::class) + ); + } + + public function testOpenGifReturnsTransparentGifWithNoCacheHeaders(): void + { + $this->loadFixtures([ + AdministratorFixture::class, + TemplateFixture::class, + MessageFixture::class, + SubscriberFixture::class, + UserMessageFixture::class, + ]); + + self::getClient()->request( + 'GET', + sprintf('/api/v2/t/open.gif?u=%s&m=%d', self::TEST_UID, self::TEST_MESSAGE_ID) + ); + + $response = self::getClient()->getResponse(); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('image/gif', $response->headers->get('Content-Type')); + self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); + self::assertSame('no-cache', $response->headers->get('Pragma')); + self::assertSame('0', $response->headers->get('Expires')); + + $expectedGif = base64_decode('R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='); + self::assertSame($expectedGif, $response->getContent()); + } + + public function testOpenGifReturnsGifEvenIfUidUnknown(): void + { + // No fixtures needed for this; the controller should still return the GIF even if tracking no-ops + self::getClient()->request('GET', '/api/v2/t/open.gif?u=unknown-uid&m=999999'); + + $response = self::getClient()->getResponse(); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('image/gif', $response->headers->get('Content-Type')); + } + + public function testOpenGifMissingParametersReturns200Anyway(): void + { + self::getClient()->request('GET', '/api/v2/t/open.gif'); + $status = self::getClient()->getResponse()->getStatusCode(); + + self::assertSame(200, $status); + } +} diff --git a/tests/Integration/Statistics/Fixtures/UserMessageFixture.php b/tests/Integration/Statistics/Fixtures/UserMessageFixture.php new file mode 100644 index 00000000..73acc93e --- /dev/null +++ b/tests/Integration/Statistics/Fixtures/UserMessageFixture.php @@ -0,0 +1,43 @@ +getRepository(Subscriber::class)->find(self::SUBSCRIBER_ID); + /** @var Message|null $message */ + $message = $manager->getRepository(Message::class)->find(self::MESSAGE_ID); + + // Doctrine may return null here when prerequisite fixtures are not loaded. + // PHPStan infers non-null from PHPDoc in some environments; suppress that false positive. + if ($subscriber === null || $message === null) { + // Pre-requisite fixtures aren't loaded; nothing to do. + return; + } + + $userMessage = new UserMessage($subscriber, $message); + $userMessage->setStatus(UserMessageStatus::Sent); + + $manager->persist($userMessage); + $manager->flush(); + } +}