From fe91d2616367423339f1f600741b051ef6c995bd Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Mon, 30 Mar 2026 08:48:03 +0200 Subject: [PATCH] Add AnalyticsControllerTest with 100% coverage (19 tests, 70 assertions) - Test token validation (invalid token returns 404) - Test request validation (missing body, missing 'd' field, invalid JSON return 400) - Test decryption validation (invalid encrypted data returns 403) - Test new visitor creation with full fields, optional fields, mobile/tablet UA - Test page view dispatch with valid hash, default values - Test page view rejection with invalid/missing hash (403) - Test setUser dispatch with valid hash - Test visitor UID format (UUID v4), IP hash, UA truncation, language truncation - Test response hash is verifiable by crypto service Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/Controller/AnalyticsControllerTest.php | 460 +++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 tests/Controller/AnalyticsControllerTest.php diff --git a/tests/Controller/AnalyticsControllerTest.php b/tests/Controller/AnalyticsControllerTest.php new file mode 100644 index 0000000..690c283 --- /dev/null +++ b/tests/Controller/AnalyticsControllerTest.php @@ -0,0 +1,460 @@ +crypto = new AnalyticsCryptoService(self::ANALYTICS_SECRET); + $this->validToken = substr(hash('sha256', self::ANALYTICS_SECRET.'_endpoint'), 0, 8); + $this->controller = new AnalyticsController(); + } + + private function createRequest(string $body, string $userAgent = 'TestAgent/1.0'): Request + { + $request = Request::create('/t/'.$this->validToken, 'POST', [], [], [], [ + 'HTTP_USER_AGENT' => $userAgent, + ], $body); + $request->headers->set('Content-Type', 'application/json'); + + return $request; + } + + private function createMockEm(?AnalyticsUniqId $persisted = null): EntityManagerInterface + { + $em = $this->createMock(EntityManagerInterface::class); + if ($persisted) { + $em->expects(self::once())->method('persist')->with(self::isInstanceOf(AnalyticsUniqId::class)); + $em->expects(self::once())->method('flush'); + } + + return $em; + } + + private function createMockBus(): MessageBusInterface + { + $bus = $this->createMock(MessageBusInterface::class); + $bus->method('dispatch')->willReturnCallback(fn ($msg) => new Envelope($msg)); + + return $bus; + } + + public function testInvalidTokenReturns404(): void + { + $request = $this->createRequest(json_encode(['d' => 'anything'])); + $em = $this->createMock(EntityManagerInterface::class); + $bus = $this->createMock(MessageBusInterface::class); + + $response = $this->controller->track('badtoken', self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertSame(404, $response->getStatusCode()); + } + + public function testMissingBodyReturns400(): void + { + $request = $this->createRequest(''); + $em = $this->createMock(EntityManagerInterface::class); + $bus = $this->createMock(MessageBusInterface::class); + + $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertSame(400, $response->getStatusCode()); + } + + public function testMissingDFieldReturns400(): void + { + $request = $this->createRequest(json_encode(['foo' => 'bar'])); + $em = $this->createMock(EntityManagerInterface::class); + $bus = $this->createMock(MessageBusInterface::class); + + $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertSame(400, $response->getStatusCode()); + } + + public function testInvalidJsonBodyReturns400(): void + { + $request = $this->createRequest('not-json'); + $em = $this->createMock(EntityManagerInterface::class); + $bus = $this->createMock(MessageBusInterface::class); + + $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertSame(400, $response->getStatusCode()); + } + + public function testInvalidEncryptedDataReturns403(): void + { + $request = $this->createRequest(json_encode(['d' => 'invalid_encrypted_payload'])); + $em = $this->createMock(EntityManagerInterface::class); + $bus = $this->createMock(MessageBusInterface::class); + + $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertSame(403, $response->getStatusCode()); + } + + public function testNewVisitorWithoutUidCreatesVisitorAndReturnsUid(): void + { + $encrypted = $this->crypto->encrypt([ + 'sw' => 1920, + 'sh' => 1080, + 'l' => 'fr-FR', + ]); + $request = $this->createRequest( + json_encode(['d' => $encrypted]), + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ); + + $persistedVisitor = null; + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('persist')->with(self::callback( + function (AnalyticsUniqId $v) use (&$persistedVisitor) { + $persistedVisitor = $v; + + return true; + } + )); + $em->expects(self::once())->method('flush'); + $bus = $this->createMock(MessageBusInterface::class); + + $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertSame(200, $response->getStatusCode()); + self::assertInstanceOf(JsonResponse::class, $response); + + $body = json_decode($response->getContent(), true); + self::assertArrayHasKey('d', $body); + + $decrypted = $this->crypto->decrypt($body['d']); + self::assertArrayHasKey('uid', $decrypted); + self::assertArrayHasKey('h', $decrypted); + + self::assertNotNull($persistedVisitor); + self::assertSame(1920, $persistedVisitor->getScreenWidth()); + self::assertSame(1080, $persistedVisitor->getScreenHeight()); + self::assertSame('fr-FR', $persistedVisitor->getLanguage()); + self::assertSame('desktop', $persistedVisitor->getDeviceType()); + self::assertSame('Windows', $persistedVisitor->getOs()); + self::assertSame('Chrome', $persistedVisitor->getBrowser()); + } + + public function testNewVisitorWithoutOptionalFieldsCreatesVisitor(): void + { + $encrypted = $this->crypto->encrypt([]); + $request = $this->createRequest(json_encode(['d' => $encrypted])); + + $persistedVisitor = null; + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('persist')->with(self::callback( + function (AnalyticsUniqId $v) use (&$persistedVisitor) { + $persistedVisitor = $v; + + return true; + } + )); + $em->expects(self::once())->method('flush'); + $bus = $this->createMock(MessageBusInterface::class); + + $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertSame(200, $response->getStatusCode()); + self::assertNotNull($persistedVisitor); + self::assertNull($persistedVisitor->getScreenWidth()); + self::assertNull($persistedVisitor->getScreenHeight()); + self::assertNull($persistedVisitor->getLanguage()); + } + + public function testNewVisitorMobileUserAgent(): void + { + $encrypted = $this->crypto->encrypt([]); + $request = $this->createRequest( + json_encode(['d' => $encrypted]), + 'Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36' + ); + + $persistedVisitor = null; + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('persist')->with(self::callback( + function (AnalyticsUniqId $v) use (&$persistedVisitor) { + $persistedVisitor = $v; + + return true; + } + )); + $em->expects(self::once())->method('flush'); + $bus = $this->createMock(MessageBusInterface::class); + + $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertSame(200, $response->getStatusCode()); + self::assertNotNull($persistedVisitor); + self::assertSame('mobile', $persistedVisitor->getDeviceType()); + self::assertSame('Android', $persistedVisitor->getOs()); + self::assertSame('Chrome', $persistedVisitor->getBrowser()); + } + + public function testNewVisitorTabletUserAgent(): void + { + $encrypted = $this->crypto->encrypt([]); + $request = $this->createRequest( + json_encode(['d' => $encrypted]), + 'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15' + ); + + $persistedVisitor = null; + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('persist')->with(self::callback( + function (AnalyticsUniqId $v) use (&$persistedVisitor) { + $persistedVisitor = $v; + + return true; + } + )); + $em->expects(self::once())->method('flush'); + $bus = $this->createMock(MessageBusInterface::class); + + $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertSame(200, $response->getStatusCode()); + self::assertNotNull($persistedVisitor); + self::assertSame('tablet', $persistedVisitor->getDeviceType()); + } + + public function testPageViewWithValidHashDispatches204(): void + { + $uid = 'test-uid-123'; + $hash = $this->crypto->generateVisitorHash($uid); + $encrypted = $this->crypto->encrypt([ + 'uid' => $uid, + 'h' => $hash, + 'u' => '/test-page', + 't' => 'Test Page', + 'r' => 'https://google.com', + ]); + $request = $this->createRequest(json_encode(['d' => $encrypted])); + $em = $this->createMock(EntityManagerInterface::class); + + $bus = $this->createMock(MessageBusInterface::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback( + fn (AnalyticsMessage $msg) => $msg->uid === $uid + && 'page_view' === $msg->action + && '/test-page' === $msg->payload['url'] + && 'Test Page' === $msg->payload['title'] + && 'https://google.com' === $msg->payload['referrer'] + ))->willReturnCallback(fn ($msg) => new Envelope($msg)); + + $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertSame(204, $response->getStatusCode()); + } + + public function testPageViewWithDefaultValues(): void + { + $uid = 'test-uid-456'; + $hash = $this->crypto->generateVisitorHash($uid); + $encrypted = $this->crypto->encrypt([ + 'uid' => $uid, + 'h' => $hash, + ]); + $request = $this->createRequest(json_encode(['d' => $encrypted])); + $em = $this->createMock(EntityManagerInterface::class); + + $bus = $this->createMock(MessageBusInterface::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback( + fn (AnalyticsMessage $msg) => '/' === $msg->payload['url'] + && null === $msg->payload['title'] + && null === $msg->payload['referrer'] + ))->willReturnCallback(fn ($msg) => new Envelope($msg)); + + $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertSame(204, $response->getStatusCode()); + } + + public function testPageViewWithInvalidHashReturns403(): void + { + $encrypted = $this->crypto->encrypt([ + 'uid' => 'some-uid', + 'h' => 'invalid_hash', + 'u' => '/test', + ]); + $request = $this->createRequest(json_encode(['d' => $encrypted])); + $em = $this->createMock(EntityManagerInterface::class); + $bus = $this->createMock(MessageBusInterface::class); + $bus->expects(self::never())->method('dispatch'); + + $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertSame(403, $response->getStatusCode()); + } + + public function testPageViewWithMissingHashReturns403(): void + { + $encrypted = $this->crypto->encrypt([ + 'uid' => 'some-uid', + 'u' => '/test', + ]); + $request = $this->createRequest(json_encode(['d' => $encrypted])); + $em = $this->createMock(EntityManagerInterface::class); + $bus = $this->createMock(MessageBusInterface::class); + $bus->expects(self::never())->method('dispatch'); + + $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertSame(403, $response->getStatusCode()); + } + + public function testSetUserWithValidHashDispatches204(): void + { + $uid = 'test-uid-789'; + $hash = $this->crypto->generateVisitorHash($uid); + $encrypted = $this->crypto->encrypt([ + 'uid' => $uid, + 'h' => $hash, + 'setUser' => 42, + ]); + $request = $this->createRequest(json_encode(['d' => $encrypted])); + $em = $this->createMock(EntityManagerInterface::class); + + $bus = $this->createMock(MessageBusInterface::class); + $bus->expects(self::once())->method('dispatch')->with(self::callback( + fn (AnalyticsMessage $msg) => $msg->uid === $uid + && 'set_user' === $msg->action + && 42 === $msg->payload['userId'] + ))->willReturnCallback(fn ($msg) => new Envelope($msg)); + + $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertSame(204, $response->getStatusCode()); + } + + public function testVisitorUidFormatIsValid(): void + { + $encrypted = $this->crypto->encrypt([]); + $request = $this->createRequest(json_encode(['d' => $encrypted])); + + $persistedVisitor = null; + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('persist')->with(self::callback( + function (AnalyticsUniqId $v) use (&$persistedVisitor) { + $persistedVisitor = $v; + + return true; + } + )); + $em->expects(self::once())->method('flush'); + $bus = $this->createMock(MessageBusInterface::class); + + $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertNotNull($persistedVisitor); + self::assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $persistedVisitor->getUid() + ); + } + + public function testVisitorIpHashIsSet(): void + { + $encrypted = $this->crypto->encrypt([]); + $request = $this->createRequest(json_encode(['d' => $encrypted])); + + $persistedVisitor = null; + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('persist')->with(self::callback( + function (AnalyticsUniqId $v) use (&$persistedVisitor) { + $persistedVisitor = $v; + + return true; + } + )); + $em->expects(self::once())->method('flush'); + $bus = $this->createMock(MessageBusInterface::class); + + $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertNotNull($persistedVisitor); + self::assertSame(64, \strlen($persistedVisitor->getIpHash())); + } + + public function testVisitorUserAgentIsTruncatedTo512(): void + { + $longUa = str_repeat('A', 600); + $encrypted = $this->crypto->encrypt([]); + $request = $this->createRequest(json_encode(['d' => $encrypted]), $longUa); + + $persistedVisitor = null; + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('persist')->with(self::callback( + function (AnalyticsUniqId $v) use (&$persistedVisitor) { + $persistedVisitor = $v; + + return true; + } + )); + $em->expects(self::once())->method('flush'); + $bus = $this->createMock(MessageBusInterface::class); + + $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertNotNull($persistedVisitor); + self::assertSame(512, \strlen($persistedVisitor->getUserAgent())); + } + + public function testVisitorLanguageIsTruncatedTo10(): void + { + $encrypted = $this->crypto->encrypt(['l' => 'fr-FR-extra-long']); + $request = $this->createRequest(json_encode(['d' => $encrypted])); + + $persistedVisitor = null; + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('persist')->with(self::callback( + function (AnalyticsUniqId $v) use (&$persistedVisitor) { + $persistedVisitor = $v; + + return true; + } + )); + $em->expects(self::once())->method('flush'); + $bus = $this->createMock(MessageBusInterface::class); + + $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + self::assertNotNull($persistedVisitor); + self::assertSame(10, \strlen($persistedVisitor->getLanguage())); + } + + public function testResponseHashIsVerifiable(): void + { + $encrypted = $this->crypto->encrypt([]); + $request = $this->createRequest(json_encode(['d' => $encrypted])); + $em = $this->createMockEm(new AnalyticsUniqId()); + $bus = $this->createMock(MessageBusInterface::class); + + $response = $this->controller->track($this->validToken, self::ANALYTICS_SECRET, $request, $this->crypto, $em, $bus); + + $body = json_decode($response->getContent(), true); + $decrypted = $this->crypto->decrypt($body['d']); + + self::assertTrue($this->crypto->verifyVisitorHash($decrypted['uid'], $decrypted['h'])); + } +}