Files
e-ticket/tests/Controller/AnalyticsControllerTest.php
Serreau Jovann fe91d26163 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) <noreply@anthropic.com>
2026-03-30 08:48:03 +02:00

461 lines
18 KiB
PHP

<?php
namespace App\Tests\Controller;
use App\Controller\AnalyticsController;
use App\Entity\AnalyticsUniqId;
use App\Message\AnalyticsMessage;
use App\Service\AnalyticsCryptoService;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
class AnalyticsControllerTest extends TestCase
{
private const ANALYTICS_SECRET = 'test_analytics_secret';
private AnalyticsCryptoService $crypto;
private string $validToken;
private AnalyticsController $controller;
protected function setUp(): void
{
$this->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']));
}
}