feat: tracking integration with GTM and CMP

This commit is contained in:
Björn Fromme
2026-01-27 17:31:41 +01:00
parent d5944bf717
commit e9548d9f38
21 changed files with 453 additions and 51 deletions
-3
View File
@@ -85,6 +85,3 @@ OAUTH_ENCRYPTION_KEY=580084fd179e67399467f59ee96658ac
# postgresql+advisory://db_user:db_password@localhost/db_name # postgresql+advisory://db_user:db_password@localhost/db_name
LOCK_DSN=flock LOCK_DSN=flock
###< symfony/lock ### ###< symfony/lock ###
# Domain-to-theme mapping (JSON format)
DOMAIN_THEME_MAP='{"ski-boarderweek.de":"sbw","semesterende-skireisen.de":"ser","snowzone.net":"snz"}'
+18
View File
@@ -0,0 +1,18 @@
parameters:
domain_config:
ep-reisen.de:
theme: base
gtm_id: GTM-56LWRF
cmp_url: https://consent.cookiefirst.com/sites/my.ep-reisen.de-28eabc35-7085-4d8f-b450-522bd1a5247c/consent.js
ski-boarderweek.de:
theme: sbw
gtm_id: GTM-MS2C8CH
cmp_url: https://consent.cookiefirst.com/sites/my.ski-boarderweek.de-d3641c34-8183-4b8d-b56e-c052e7a9223e/consent.js
semesterende-skireisen.de:
theme: ser
gtm_id: GTM-MNKDKL9
cmp_url: https://consent.cookiefirst.com/sites/my.semesterende-skireisen.de-c9b3422d-eb62-4ce4-b8f7-91e9b509e26f/consent.js
snowzone.net:
theme: snz
gtm_id: GTM-M96CKL
cmp_url: https://consent.cookiefirst.com/sites/my.snowzone.net-8e6aea0c-7d93-42a0-a822-29fff2aa5613/consent.js
+5 -3
View File
@@ -3,13 +3,15 @@
# Put parameters here that don't need to change on each machine where the app is deployed # Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
imports:
- { resource: domain_config.yaml }
parameters: parameters:
travel_info_base_url: '%env(APP_TRAVEL_INFO_BASE_URL)%' travel_info_base_url: '%env(APP_TRAVEL_INFO_BASE_URL)%'
terms_and_conditions_url: '%env(APP_TERMS_AND_CONDITIONS_URL)%' terms_and_conditions_url: '%env(APP_TERMS_AND_CONDITIONS_URL)%'
path_to_keys: '%kernel.project_dir%/config/secret' path_to_keys: '%kernel.project_dir%/config/secret'
bpn_debug: '%env(APP_BPN_DEBUG)%' bpn_debug: '%env(APP_BPN_DEBUG)%'
default_booking_status: '%env(DEFAULT_BOOKING_STATUS)%' default_booking_status: '%env(DEFAULT_BOOKING_STATUS)%'
domain_theme_map: '%env(json:DOMAIN_THEME_MAP)%'
# Body dimensions choices for BodyDimensionsType # Body dimensions choices for BodyDimensionsType
body_dimensions.height_choices: body_dimensions.height_choices:
@@ -147,7 +149,7 @@ services:
tags: tags:
- { name: monolog.processor } - { name: monolog.processor }
App\EventListener\DomainThemeListener: App\Service\DomainConfigProvider:
arguments: arguments:
$domainThemeMap: '%domain_theme_map%' $domainConfig: '%domain_config%'
@@ -92,8 +92,11 @@ class Step4Controller extends AbstractController
); );
} }
// Success: Store booking number in flash and clear session // Success: Store booking data in flash for conversion tracking
$summaryData = $this->summaryDataService->getSummaryData($bookingCreateDto);
$this->addFlash('booking_number', $bookingResponse->bookingNumber); $this->addFlash('booking_number', $bookingResponse->bookingNumber);
$this->addFlash('booking_total', $summaryData->payableAmount);
$this->addFlash('booking_travel_name', $bookingCreateDto->travel->label);
$this->clearTravelDataCache($bookingCreateDto); $this->clearTravelDataCache($bookingCreateDto);
$this->bookingService->clearBookingDto($request, BookingDto::MODE_CREATE); $this->bookingService->clearBookingDto($request, BookingDto::MODE_CREATE);
@@ -23,7 +23,10 @@ class SuccessController extends AbstractController
#[Route('/bookings/create/success', name: 'app_booking_create_success')] #[Route('/bookings/create/success', name: 'app_booking_create_success')]
public function success(Request $request): Response public function success(Request $request): Response
{ {
$bookingNumber = $request->getSession()->getFlashBag()->get('booking_number')[0] ?? null; $flashBag = $request->getSession()->getFlashBag();
$bookingNumber = $flashBag->get('booking_number')[0] ?? null;
$bookingTotal = $flashBag->get('booking_total')[0] ?? null;
$travelName = $flashBag->get('booking_travel_name')[0] ?? null;
$returnUrl = $this->bookingService->getReturnUrl($request); $returnUrl = $this->bookingService->getReturnUrl($request);
// Redirect to return URL if no booking number (direct access or refresh) // Redirect to return URL if no booking number (direct access or refresh)
@@ -33,6 +36,8 @@ class SuccessController extends AbstractController
return $this->render('booking/create/success.html.twig', [ return $this->render('booking/create/success.html.twig', [
'bookingNumber' => $bookingNumber, 'bookingNumber' => $bookingNumber,
'bookingTotal' => $bookingTotal,
'travelName' => $travelName,
'returnUrl' => $returnUrl, 'returnUrl' => $returnUrl,
]); ]);
} }
+6 -23
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\EventListener; namespace App\EventListener;
use App\Service\DomainConfigProvider;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\HttpKernel\KernelEvents;
@@ -12,13 +13,10 @@ use Symfony\Component\HttpKernel\KernelEvents;
class DomainThemeListener class DomainThemeListener
{ {
public const THEME_ATTRIBUTE = '_theme'; public const THEME_ATTRIBUTE = '_theme';
private const DEFAULT_THEME = 'base'; public const DOMAIN_CONFIG_ATTRIBUTE = '_domain_config';
/**
* @param array<string, string> $domainThemeMap
*/
public function __construct( public function __construct(
private readonly array $domainThemeMap, private readonly DomainConfigProvider $domainConfigProvider,
) { ) {
} }
@@ -29,24 +27,9 @@ class DomainThemeListener
} }
$request = $event->getRequest(); $request = $event->getRequest();
$host = $request->getHost(); $config = $this->domainConfigProvider->getConfigForHost($request->getHost());
$theme = $this->resolveTheme($host); $request->attributes->set(self::THEME_ATTRIBUTE, $config->theme);
$request->attributes->set(self::THEME_ATTRIBUTE, $theme); $request->attributes->set(self::DOMAIN_CONFIG_ATTRIBUTE, $config);
}
private function resolveTheme(string $host): string
{
$host = strtolower($host);
foreach ($this->domainThemeMap as $pattern => $theme) {
$pattern = strtolower($pattern);
if ($host === $pattern || str_ends_with($host, '.' . $pattern)) {
return $theme;
}
}
return self::DEFAULT_THEME;
} }
} }
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Model;
/**
* Value object representing domain-specific configuration.
*
* Holds theme key, GTM container ID, and CMP (Consent Management Platform) URL
* for a specific domain. Used by DomainConfigProvider to resolve configuration
* based on the request host.
*/
final readonly class DomainConfig
{
public const DEFAULT_THEME = 'base';
public function __construct(
public string $theme = self::DEFAULT_THEME,
public string $gtmId = '',
public string $cmpUrl = '',
) {
}
/**
* Creates a DomainConfig instance from a configuration array.
*
* @param array{theme?: string, gtm_id?: string, cmp_url?: string} $config The configuration array
*/
public static function fromArray(array $config): self
{
return new self(
theme: $config['theme'] ?? self::DEFAULT_THEME,
gtmId: $config['gtm_id'] ?? '',
cmpUrl: $config['cmp_url'] ?? '',
);
}
/**
* Creates a default DomainConfig instance with base theme and empty tracking values.
*/
public static function default(): self
{
return new self();
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Model\DomainConfig;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Resolves domain-specific configuration based on the current request host.
*
* Matches the request host against configured domain patterns, supporting both
* exact matches and subdomain matching (e.g., "example.com" matches "www.example.com").
* Returns a default configuration when no matching domain is found.
*/
class DomainConfigProvider
{
/**
* @var array<string, DomainConfig>
*/
private array $configCache = [];
/**
* @param array<string, array{theme?: string, gtm_id?: string, cmp_url?: string}> $domainConfig
*/
public function __construct(
private readonly array $domainConfig,
private readonly RequestStack $requestStack,
) {
}
/**
* Returns the configuration for the current request host.
*/
public function getConfig(): DomainConfig
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return DomainConfig::default();
}
return $this->getConfigForHost($request->getHost());
}
/**
* Returns the configuration for a specific host.
*
* @param string $host The host to resolve configuration for
*/
public function getConfigForHost(string $host): DomainConfig
{
$host = strtolower($host);
if (isset($this->configCache[$host])) {
return $this->configCache[$host];
}
foreach ($this->domainConfig as $pattern => $config) {
$pattern = strtolower($pattern);
if ($host === $pattern || str_ends_with($host, '.'.$pattern)) {
$domainConfig = DomainConfig::fromArray($config);
$this->configCache[$host] = $domainConfig;
return $domainConfig;
}
}
$this->configCache[$host] = DomainConfig::default();
return $this->configCache[$host];
}
}
+2
View File
@@ -40,6 +40,8 @@ class AppExtension extends AbstractExtension
new TwigFunction('is_hidden', [AppRuntime::class, 'isHidden']), new TwigFunction('is_hidden', [AppRuntime::class, 'isHidden']),
new TwigFunction('collect_invalid_field_labels', [AppRuntime::class, 'collectInvalidFieldLabels']), new TwigFunction('collect_invalid_field_labels', [AppRuntime::class, 'collectInvalidFieldLabels']),
new TwigFunction('booking_theme', [AppRuntime::class, 'getBookingTheme']), new TwigFunction('booking_theme', [AppRuntime::class, 'getBookingTheme']),
new TwigFunction('gtm_id', [AppRuntime::class, 'getGtmId']),
new TwigFunction('cmp_url', [AppRuntime::class, 'getCmpUrl']),
]; ];
} }
} }
+31 -2
View File
@@ -9,6 +9,7 @@ use App\EventListener\DomainThemeListener;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Service\CreateFieldStateProvider; use App\Form\Service\CreateFieldStateProvider;
use App\Form\Service\EditFieldStateProvider; use App\Form\Service\EditFieldStateProvider;
use App\Model\DomainConfig;
use App\Service\ParticipantEligibilityService; use App\Service\ParticipantEligibilityService;
use Symfony\Component\Form\FormView; use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
@@ -234,14 +235,42 @@ class AppRuntime implements RuntimeExtensionInterface
* Defaults to 'base' if no theme is set. * Defaults to 'base' if no theme is set.
*/ */
public function getBookingTheme(): string public function getBookingTheme(): string
{
return $this->getDomainConfig()->theme;
}
/**
* Returns the GTM container ID for the current domain.
*
* Returns an empty string if no GTM ID is configured for this domain.
*/
public function getGtmId(): string
{
return $this->getDomainConfig()->gtmId;
}
/**
* Returns the CMP (Consent Management Platform) URL for the current domain.
*
* Returns an empty string if no CMP URL is configured for this domain.
*/
public function getCmpUrl(): string
{
return $this->getDomainConfig()->cmpUrl;
}
private function getDomainConfig(): DomainConfig
{ {
$request = $this->requestStack->getCurrentRequest(); $request = $this->requestStack->getCurrentRequest();
if (null === $request) { if (null === $request) {
return 'base'; return DomainConfig::default();
} }
return $request->attributes->get(DomainThemeListener::THEME_ATTRIBUTE, 'base'); return $request->attributes->get(
DomainThemeListener::DOMAIN_CONFIG_ATTRIBUTE,
DomainConfig::default()
);
} }
/** /**
+12
View File
@@ -0,0 +1,12 @@
<script>
window.dataLayer = window.dataLayer || [];
var consent = (window.CookieFirst && window.CookieFirst.consent) || { advertising: false };
if (consent.advertising) {
dataLayer.push({
'event':'VirtualPageview',
'virtualPageURL': '{{ url }}',
'virtualPageTitle': '{{ title }}',
'userInfo': '{{ is_granted('ROLE_USER') ? 'mitglied' : 'gast' }}'
});
}
</script>
+10
View File
@@ -8,6 +8,16 @@
<link rel="icon" href="{{ asset('build/favicon/favicon.ico') }}" sizes="32x32"> <link rel="icon" href="{{ asset('build/favicon/favicon.ico') }}" sizes="32x32">
<link rel="icon" href="{{ asset('build/favicon/icon.svg') }}" type="image/svg+xml"> <link rel="icon" href="{{ asset('build/favicon/icon.svg') }}" type="image/svg+xml">
<link rel="apple-touch-icon" href="{{ asset('build/favicon/apple-touch-icon.png') }}"> <link rel="apple-touch-icon" href="{{ asset('build/favicon/apple-touch-icon.png') }}">
{% if cmp_url() is not empty %}
<script src="{{ cmp_url() }}"></script>
<script type="text/plain" data-cookiefirst-category="necessary">
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','{{ gtm_id() }}');
</script>
{% endif %}
{% block stylesheets %} {% block stylesheets %}
{{ encore_entry_link_tags('app') }} {{ encore_entry_link_tags('app') }}
{% endblock %} {% endblock %}
@@ -95,6 +95,10 @@
</div> </div>
</div> </div>
{% include '_partials/_tracking.html.twig' with {
'url': '/trichter_Z1/schritt0_login_oder_gast',
'title': 'Login oder Gast'
} %}
{% endblock %} {% endblock %}
{% block footer %} {% block footer %}
-1
View File
@@ -6,7 +6,6 @@
date_id: params.dateId, date_id: params.dateId,
hotel_id: params.hotelId, hotel_id: params.hotelId,
agency: params.agency, agency: params.agency,
theme: params.theme,
r: params.returnUrl r: params.returnUrl
}) }}" }) }}"
hx-trigger="load" hx-trigger="load"
@@ -101,4 +101,8 @@
</div> </div>
{{ form_rest(form) }} {{ form_rest(form) }}
{{ form_end(form) }} {{ form_end(form) }}
{% include '_partials/_tracking.html.twig' with {
'url': '/trichter_Z1/schritt1_zimmerauswahl',
'title': 'Zimmerauswahl'
} %}
{% endblock %} {% endblock %}
@@ -75,4 +75,8 @@
</div> </div>
{{ form_rest(form) }} {{ form_rest(form) }}
{{ form_end(form) }} {{ form_end(form) }}
{% include '_partials/_tracking.html.twig' with {
'url': '/trichter_Z1/schritt2_teilnehmende_leistungen',
'title': 'Teilnehmende und Leistungen'
} %}
{% endblock %} {% endblock %}
@@ -85,4 +85,8 @@
</div> </div>
{{ form_rest(form) }} {{ form_rest(form) }}
{{ form_end(form) }} {{ form_end(form) }}
{% include '_partials/_tracking.html.twig' with {
'url': '/trichter_Z1/schritt3_zahlungsart',
'title': 'Zahlungsart'
} %}
{% endblock %} {% endblock %}
@@ -567,4 +567,8 @@
</div> </div>
{{ form_rest(form) }} {{ form_rest(form) }}
{{ form_end(form) }} {{ form_end(form) }}
{% include '_partials/_tracking.html.twig' with {
'url': '/trichter_Z1/schritt4_uerbersicht_bestaetigung',
'title': 'Übersicht und Bestätigung'
} %}
{% endblock %} {% endblock %}
@@ -55,4 +55,23 @@
</div> </div>
</div> </div>
{% if bookingTotal is not null and travelName is not null %}
<script>
window.dataLayer = window.dataLayer || [];
var consent = (window.CookieFirst && window.CookieFirst.consent) || { advertising: false };
if (consent.advertising) {
dataLayer.push({
'event': 'Transaction',
'userInfo': '{{ is_granted('ROLE_USER') ? 'mitglied' : 'gast' }}',
'transactionId': '{{ bookingNumber }}',
'transactionTotal': {{ bookingTotal }},
'transactionProducts': [{
'name': '{{ travelName|e('js') }}',
'price': {{ bookingTotal }},
'quantity': 1
}]
});
}
</script>
{% endif %}
{% endblock %} {% endblock %}
+54 -17
View File
@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace App\Tests\EventListener; namespace App\Tests\EventListener;
use App\EventListener\DomainThemeListener; use App\EventListener\DomainThemeListener;
use App\Model\DomainConfig;
use App\Service\DomainConfigProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\HttpKernelInterface;
@@ -21,9 +24,9 @@ class DomainThemeListenerTest extends TestCase
public function testSetsThemeAttributeForMatchingDomain(): void public function testSetsThemeAttributeForMatchingDomain(): void
{ {
$listener = new DomainThemeListener([ $listener = $this->createListener([
'sbw-reisen' => 'sbw', 'sbw-reisen.de' => ['theme' => 'sbw'],
'ser-reisen' => 'ser', 'ser-reisen.de' => ['theme' => 'ser'],
]); ]);
$request = Request::create('https://www.sbw-reisen.de/bookings/create'); $request = Request::create('https://www.sbw-reisen.de/bookings/create');
@@ -34,10 +37,32 @@ class DomainThemeListenerTest extends TestCase
$this->assertSame('sbw', $request->attributes->get(DomainThemeListener::THEME_ATTRIBUTE)); $this->assertSame('sbw', $request->attributes->get(DomainThemeListener::THEME_ATTRIBUTE));
} }
public function testSetsDomainConfigAttribute(): void
{
$listener = $this->createListener([
'sbw-reisen.de' => [
'theme' => 'sbw',
'gtm_id' => 'GTM-123',
'cmp_url' => 'https://cmp.example.com',
],
]);
$request = Request::create('https://www.sbw-reisen.de/bookings/create');
$event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$listener($event);
$config = $request->attributes->get(DomainThemeListener::DOMAIN_CONFIG_ATTRIBUTE);
$this->assertInstanceOf(DomainConfig::class, $config);
$this->assertSame('sbw', $config->theme);
$this->assertSame('GTM-123', $config->gtmId);
$this->assertSame('https://cmp.example.com', $config->cmpUrl);
}
public function testSetsDefaultThemeForUnknownDomain(): void public function testSetsDefaultThemeForUnknownDomain(): void
{ {
$listener = new DomainThemeListener([ $listener = $this->createListener([
'sbw-reisen' => 'sbw', 'sbw-reisen.de' => ['theme' => 'sbw'],
]); ]);
$request = Request::create('https://www.ep-reisen.de/bookings/create'); $request = Request::create('https://www.ep-reisen.de/bookings/create');
@@ -50,8 +75,8 @@ class DomainThemeListenerTest extends TestCase
public function testMatchesPartialDomain(): void public function testMatchesPartialDomain(): void
{ {
$listener = new DomainThemeListener([ $listener = $this->createListener([
'ser-reisen' => 'ser', 'ser-reisen.de' => ['theme' => 'ser'],
]); ]);
$request = Request::create('https://portal.ser-reisen.de/bookings'); $request = Request::create('https://portal.ser-reisen.de/bookings');
@@ -64,8 +89,8 @@ class DomainThemeListenerTest extends TestCase
public function testIgnoresSubRequests(): void public function testIgnoresSubRequests(): void
{ {
$listener = new DomainThemeListener([ $listener = $this->createListener([
'sbw-reisen' => 'sbw', 'sbw-reisen.de' => ['theme' => 'sbw'],
]); ]);
$request = Request::create('https://www.sbw-reisen.de/bookings/create'); $request = Request::create('https://www.sbw-reisen.de/bookings/create');
@@ -78,22 +103,23 @@ class DomainThemeListenerTest extends TestCase
public function testFirstMatchWins(): void public function testFirstMatchWins(): void
{ {
$listener = new DomainThemeListener([ // When a host matches multiple patterns, the first pattern in iteration order wins
'reisen' => 'generic', $listener = $this->createListener([
'sbw-reisen' => 'sbw', 'portal.example.com' => ['theme' => 'portal'],
'example.com' => ['theme' => 'main'],
]); ]);
$request = Request::create('https://www.sbw-reisen.de/bookings/create'); $request = Request::create('https://deep.portal.example.com/bookings/create');
$event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST); $event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$listener($event); $listener($event);
$this->assertSame('generic', $request->attributes->get(DomainThemeListener::THEME_ATTRIBUTE)); $this->assertSame('portal', $request->attributes->get(DomainThemeListener::THEME_ATTRIBUTE));
} }
public function testEmptyMapReturnsDefaultTheme(): void public function testEmptyMapReturnsDefaultTheme(): void
{ {
$listener = new DomainThemeListener([]); $listener = $this->createListener([]);
$request = Request::create('https://www.sbw-reisen.de/bookings/create'); $request = Request::create('https://www.sbw-reisen.de/bookings/create');
$event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST); $event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
@@ -105,8 +131,8 @@ class DomainThemeListenerTest extends TestCase
public function testMatchesLocalhostForDevelopment(): void public function testMatchesLocalhostForDevelopment(): void
{ {
$listener = new DomainThemeListener([ $listener = $this->createListener([
'localhost' => 'sbw', 'localhost' => ['theme' => 'sbw'],
]); ]);
$request = Request::create('http://localhost/bookings/create'); $request = Request::create('http://localhost/bookings/create');
@@ -116,4 +142,15 @@ class DomainThemeListenerTest extends TestCase
$this->assertSame('sbw', $request->attributes->get(DomainThemeListener::THEME_ATTRIBUTE)); $this->assertSame('sbw', $request->attributes->get(DomainThemeListener::THEME_ATTRIBUTE));
} }
/**
* @param array<string, array{theme?: string, gtm_id?: string, cmp_url?: string}> $domainConfig
*/
private function createListener(array $domainConfig): DomainThemeListener
{
$requestStack = new RequestStack();
$provider = new DomainConfigProvider($domainConfig, $requestStack);
return new DomainThemeListener($provider);
}
} }
+145
View File
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Model\DomainConfig;
use App\Service\DomainConfigProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
class DomainConfigProviderTest extends TestCase
{
public function testReturnsConfigForExactMatch(): void
{
$provider = $this->createProvider([
'example.com' => [
'theme' => 'custom',
'gtm_id' => 'GTM-123',
'cmp_url' => 'https://cmp.example.com',
],
], 'example.com');
$config = $provider->getConfig();
$this->assertSame('custom', $config->theme);
$this->assertSame('GTM-123', $config->gtmId);
$this->assertSame('https://cmp.example.com', $config->cmpUrl);
}
public function testReturnsConfigForSubdomainMatch(): void
{
$provider = $this->createProvider([
'example.com' => [
'theme' => 'custom',
'gtm_id' => 'GTM-456',
'cmp_url' => 'https://cmp.example.com',
],
], 'www.example.com');
$config = $provider->getConfig();
$this->assertSame('custom', $config->theme);
$this->assertSame('GTM-456', $config->gtmId);
}
public function testReturnsDefaultConfigForUnknownDomain(): void
{
$provider = $this->createProvider([
'example.com' => [
'theme' => 'custom',
'gtm_id' => 'GTM-123',
'cmp_url' => 'https://cmp.example.com',
],
], 'unknown.com');
$config = $provider->getConfig();
$this->assertSame(DomainConfig::DEFAULT_THEME, $config->theme);
$this->assertSame('', $config->gtmId);
$this->assertSame('', $config->cmpUrl);
}
public function testReturnsDefaultConfigWhenNoRequest(): void
{
$requestStack = new RequestStack();
$provider = new DomainConfigProvider([], $requestStack);
$config = $provider->getConfig();
$this->assertSame(DomainConfig::DEFAULT_THEME, $config->theme);
}
public function testHandlesMissingConfigValues(): void
{
$provider = $this->createProvider([
'example.com' => [
'theme' => 'custom',
],
], 'example.com');
$config = $provider->getConfig();
$this->assertSame('custom', $config->theme);
$this->assertSame('', $config->gtmId);
$this->assertSame('', $config->cmpUrl);
}
public function testCachesConfigForSameHost(): void
{
$provider = $this->createProvider([
'example.com' => [
'theme' => 'custom',
'gtm_id' => 'GTM-123',
'cmp_url' => 'https://cmp.example.com',
],
], 'example.com');
$config1 = $provider->getConfigForHost('example.com');
$config2 = $provider->getConfigForHost('example.com');
$this->assertSame($config1, $config2);
}
public function testFirstMatchWins(): void
{
// When a host matches multiple patterns, the first pattern in iteration order wins
// Here 'deep.portal.example.com' matches both 'portal.example.com' (via subdomain)
// and 'example.com' (via subdomain), but portal.example.com is checked first
$provider = $this->createProvider([
'portal.example.com' => ['theme' => 'portal'],
'example.com' => ['theme' => 'main'],
], 'deep.portal.example.com');
$config = $provider->getConfig();
$this->assertSame('portal', $config->theme);
}
public function testCaseInsensitiveMatching(): void
{
$provider = $this->createProvider([
'Example.COM' => [
'theme' => 'custom',
],
], 'EXAMPLE.com');
$config = $provider->getConfig();
$this->assertSame('custom', $config->theme);
}
/**
* @param array<string, array{theme?: string, gtm_id?: string, cmp_url?: string}> $domainConfig
*/
private function createProvider(array $domainConfig, string $host): DomainConfigProvider
{
$request = Request::create('https://'.$host.'/');
$requestStack = new RequestStack();
$requestStack->push($request);
return new DomainConfigProvider($domainConfig, $requestStack);
}
}