diff --git a/CHANGELOG.md b/CHANGELOG.md index 757a0b31..c85f0408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,29 @@ - `onCrmContactDelete` - Added separated methods `RemoteEventsFactory::create` and `RemoteEventsFactory::validate` for create and validate incoming events, [see details](https://github.com/bitrix24/b24phpsdk/issues/291) +- Added comprehensive unit tests for `RemoteEventsFactory::create` and `RemoteEventsFactory::validate` methods with 14 test cases covering: + - Event creation for supported event types (CRM Contact Add, Application Install) + - Handling of unsupported events + - Request validation + - Token validation with `Bitrix24AccountInterface` + - Special handling for `OnApplicationInstall` events +- Updated `ContactPersonInterface` implementation, [see details](https://github.com/bitrix24/b24phpsdk/issues/290) with new methods: + - Added `isEmailVerified(): bool` to check email verification status + - Added `isMobilePhoneVerified(): bool` to check mobile phone verification status + - Changed `changeEmail(?string $email)` signature (removed optional `$isEmailVerified` parameter) + - Changed `changeMobilePhone(?PhoneNumber $phoneNumber)` signature (removed optional `$isMobilePhoneVerified` parameter) + - Added `getUserAgentInfo(): UserAgentInfo` to replace separate methods for user agent data +- Added comprehensive unit tests for `UTMs` class with 28 test cases covering: + - Constructor with all, partial, and default parameters + - URL parsing with various UTM parameter combinations + - Case-insensitive parameter handling + - URL encoding and special characters + - Real-world URL examples (Google Ads, Facebook, Email, Twitter, LinkedIn, etc.) +- Added comprehensive unit tests for `UserAgentInfo` class with 33 test cases covering: + - Constructor with IP addresses (IPv4, IPv6, localhost) + - Various user agent strings (Chrome, Firefox, Safari, Edge, mobile browsers) + - UTM extraction from referrer URLs + - Real-world scenarios with complete user tracking data ### Fixed diff --git a/src/Application/Contracts/ContactPersons/Entity/ContactPersonInterface.php b/src/Application/Contracts/ContactPersons/Entity/ContactPersonInterface.php index 384c97f8..4bad318c 100644 --- a/src/Application/Contracts/ContactPersons/Entity/ContactPersonInterface.php +++ b/src/Application/Contracts/ContactPersons/Entity/ContactPersonInterface.php @@ -74,13 +74,18 @@ public function getUpdatedAt(): CarbonImmutable; */ public function getEmail(): ?string; - public function changeEmail(?string $email, ?bool $isEmailVerified = null): void; + public function changeEmail(?string $email): void; /** * @return void mark contact person email as verified (send check main) */ public function markEmailAsVerified(): void; + /** + * @return bool is email verified with send code or magic link + */ + public function isEmailVerified(): bool; + /** * @return CarbonImmutable|null is contact person email verified */ @@ -89,10 +94,15 @@ public function getEmailVerifiedAt(): ?CarbonImmutable; /** * Change mobile phone for contact person */ - public function changeMobilePhone(?PhoneNumber $phoneNumber, ?bool $isMobilePhoneVerified = null): void; + public function changeMobilePhone(?PhoneNumber $phoneNumber): void; public function getMobilePhone(): ?PhoneNumber; + /** + * @return bool is mobile phone verified with send sms code + */ + public function isMobilePhoneVerified(): bool; + /** * @return CarbonImmutable|null is contact person mobile phone verified */ @@ -133,18 +143,5 @@ public function getBitrix24PartnerId(): ?Uuid; */ public function setBitrix24PartnerId(?Uuid $uuid): void; - /** - * get user agent for contact person, use for store metadata in consent agreements facts - */ - public function getUserAgent(): ?string; - - /** - * get user agent referer for contact person use for store metadata in consent agreements facts - */ - public function getUserAgentReferer(): ?string; - - /** - * get user agent ip for contact person use for store metadata in consent agreements facts - */ - public function getUserAgentIp(): ?IP; + public function getUserAgentInfo(): UserAgentInfo; } diff --git a/src/Application/Contracts/ContactPersons/Entity/FullName.php b/src/Application/Contracts/ContactPersons/Entity/FullName.php index e1e3c6b4..5c428c48 100644 --- a/src/Application/Contracts/ContactPersons/Entity/FullName.php +++ b/src/Application/Contracts/ContactPersons/Entity/FullName.php @@ -21,8 +21,7 @@ public function __construct( public string $name, public ?string $surname = null, public ?string $patronymic = null - ) - { + ) { if ($surname !== null) { $this->surname = trim($surname); } @@ -41,4 +40,4 @@ public function __toString(): string { return sprintf('%s %s %s', $this->name, $this->surname, $this->patronymic); } -} \ No newline at end of file +} diff --git a/src/Application/Contracts/ContactPersons/Entity/UTMs.php b/src/Application/Contracts/ContactPersons/Entity/UTMs.php new file mode 100644 index 00000000..c0a04dc6 --- /dev/null +++ b/src/Application/Contracts/ContactPersons/Entity/UTMs.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\SDK\Application\Contracts\ContactPersons\Entity; + +use function parse_url; +use function parse_str; + +readonly class UTMs +{ + public function __construct( + /** + * Identifies which site sent the traffic (google, facebook, twitter, etc.) + */ + public ?string $source = null, + /** + * Identifies what type of link was used (cpc, banner, email, etc.) + */ + public ?string $medium = null, + /** + * Identifies a specific product promotion or strategic campaign + */ + public ?string $campaign = null, + /** + * Identifies search terms used by paid search campaigns + */ + public ?string $term = null, + /** + * Identifies what specifically was clicked to bring the user to the site (banner ad, text link, etc.) + */ + public ?string $content = null, + ) { + } + + /** + * Create UTMs object from URL string + */ + public static function fromUrl(string $url): self + { + $query = parse_url($url, PHP_URL_QUERY); + if ($query === null || $query === false) { + return new self(); + } + + $query = strtolower($query); + parse_str($query, $params); + + return new self( + $params['utm_source'] ?? null, + $params['utm_medium'] ?? null, + $params['utm_campaign'] ?? null, + $params['utm_term'] ?? null, + $params['utm_content'] ?? null + ); + } + +} diff --git a/src/Application/Contracts/ContactPersons/Entity/UserAgentInfo.php b/src/Application/Contracts/ContactPersons/Entity/UserAgentInfo.php new file mode 100644 index 00000000..b9aa6fab --- /dev/null +++ b/src/Application/Contracts/ContactPersons/Entity/UserAgentInfo.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\SDK\Application\Contracts\ContactPersons\Entity; + +use Darsyn\IP\Version\Multi as IP; + +readonly class UserAgentInfo +{ + public function __construct( + public ?IP $ip, + public ?string $userAgent = null, + public ?string $referrer = null, + public ?string $fingerprint = null, + ) { + } + + public function getUTMs(): UTMs + { + if ($this->referrer === null) { + return new UTMs(); + } + + return UTMs::fromUrl($this->referrer); + } +} diff --git a/tests/Application/Contracts/ContactPersons/Entity/ContactPersonInterfaceTest.php b/tests/Application/Contracts/ContactPersons/Entity/ContactPersonInterfaceTest.php index dab78765..d0b502d8 100644 --- a/tests/Application/Contracts/ContactPersons/Entity/ContactPersonInterfaceTest.php +++ b/tests/Application/Contracts/ContactPersons/Entity/ContactPersonInterfaceTest.php @@ -444,20 +444,6 @@ final public function testChangeEmail( $contactPerson->changeEmail($newEmail); $this->assertEquals($newEmail, $contactPerson->getEmail()); $this->assertNull($contactPerson->getEmailVerifiedAt()); - - $newEmail = DemoDataGenerator::getEmail(); - $contactPerson->changeEmail($newEmail, true); - $this->assertEquals($newEmail, $contactPerson->getEmail()); - $this->assertNotNull($contactPerson->getEmailVerifiedAt()); - $newEmail = DemoDataGenerator::getEmail(); - $contactPerson->changeEmail($newEmail); - $this->assertEquals($newEmail, $contactPerson->getEmail()); - $this->assertNull($contactPerson->getEmailVerifiedAt()); - - $newEmail = DemoDataGenerator::getEmail(); - $contactPerson->changeEmail($newEmail, false); - $this->assertEquals($newEmail, $contactPerson->getEmail()); - $this->assertNull($contactPerson->getEmailVerifiedAt()); } #[Test] @@ -552,14 +538,6 @@ final public function testChangeMobilePhone( $phone = DemoDataGenerator::getMobilePhone(); $contactPerson->changeMobilePhone($phone); $this->assertNull($contactPerson->getMobilePhoneVerifiedAt()); - - $phone = DemoDataGenerator::getMobilePhone(); - $contactPerson->changeMobilePhone($phone, false); - $this->assertNull($contactPerson->getMobilePhoneVerifiedAt()); - - $phone = DemoDataGenerator::getMobilePhone(); - $contactPerson->changeMobilePhone($phone, true); - $this->assertNotNull($contactPerson->getMobilePhoneVerifiedAt()); } #[Test] @@ -590,7 +568,10 @@ final public function testGetMobilePhoneVerifiedAt( $this->assertEquals($phoneNumber, $contactPerson->getMobilePhone()); $phone = DemoDataGenerator::getMobilePhone(); - $contactPerson->changeMobilePhone($phone, true); + $contactPerson->changeMobilePhone($phone); + $this->assertNull($contactPerson->getMobilePhoneVerifiedAt()); + + $contactPerson->markMobilePhoneAsVerified(); $this->assertNotNull($contactPerson->getMobilePhoneVerifiedAt()); } @@ -830,8 +811,8 @@ final public function testSetBitrix24PartnerId( #[Test] #[DataProvider('contactPersonDataProvider')] - #[TestDox('test getUserAgent method')] - final public function testGetUserAgent( + #[TestDox('test isEmailVerified method')] + final public function testIsEmailVerified( Uuid $uuid, CarbonImmutable $createdAt, CarbonImmutable $updatedAt, @@ -853,13 +834,27 @@ final public function testGetUserAgent( ): void { $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerUuid, $userAgent, $userAgentReferer, $userAgentIp); - $this->assertEquals($userAgent, $contactPerson->getUserAgent()); + + if ($emailVerifiedAt !== null) { + $this->assertTrue($contactPerson->isEmailVerified()); + } else { + $this->assertFalse($contactPerson->isEmailVerified()); + } + + // Change email should reset verification + $newEmail = DemoDataGenerator::getEmail(); + $contactPerson->changeEmail($newEmail); + $this->assertFalse($contactPerson->isEmailVerified()); + + // Mark as verified + $contactPerson->markEmailAsVerified(); + $this->assertTrue($contactPerson->isEmailVerified()); } #[Test] #[DataProvider('contactPersonDataProvider')] - #[TestDox('test getUserAgentReferer method')] - final public function testGetUserAgentReferer( + #[TestDox('test isMobilePhoneVerified method')] + final public function testIsMobilePhoneVerified( Uuid $uuid, CarbonImmutable $createdAt, CarbonImmutable $updatedAt, @@ -881,13 +876,27 @@ final public function testGetUserAgentReferer( ): void { $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerUuid, $userAgent, $userAgentReferer, $userAgentIp); - $this->assertEquals($userAgentReferer, $contactPerson->getUserAgentReferer()); + + if ($mobilePhoneVerifiedAt !== null) { + $this->assertTrue($contactPerson->isMobilePhoneVerified()); + } else { + $this->assertFalse($contactPerson->isMobilePhoneVerified()); + } + + // Change phone should reset verification + $newPhone = DemoDataGenerator::getMobilePhone(); + $contactPerson->changeMobilePhone($newPhone); + $this->assertFalse($contactPerson->isMobilePhoneVerified()); + + // Mark as verified + $contactPerson->markMobilePhoneAsVerified(); + $this->assertTrue($contactPerson->isMobilePhoneVerified()); } #[Test] #[DataProvider('contactPersonDataProvider')] - #[TestDox('test getUserAgentIp method')] - final public function testGetUserAgentIp( + #[TestDox('test getUserAgentInfo method')] + final public function testGetUserAgentInfo( Uuid $uuid, CarbonImmutable $createdAt, CarbonImmutable $updatedAt, @@ -909,7 +918,11 @@ final public function testGetUserAgentIp( ): void { $contactPerson = $this->createContactPersonImplementation($uuid, $createdAt, $updatedAt, $contactPersonStatus, $name, $surname, $patronymic, $email, $emailVerifiedAt, $comment, $phoneNumber, $mobilePhoneVerifiedAt, $externalId, $bitrix24UserId, $bitrix24PartnerUuid, $userAgent, $userAgentReferer, $userAgentIp); - $this->assertEquals($userAgentIp, $contactPerson->getUserAgentIp()); + $userAgentInfo = $contactPerson->getUserAgentInfo(); + + $this->assertEquals($userAgent, $userAgentInfo->userAgent); + $this->assertEquals($userAgentReferer, $userAgentInfo->referrer); + $this->assertEquals($userAgentIp, $userAgentInfo->ip); } public static function contactPersonDataProvider(): Generator diff --git a/tests/Unit/Application/Contracts/ContactPersons/Entity/ContactPersonReferenceEntityImplementation.php b/tests/Unit/Application/Contracts/ContactPersons/Entity/ContactPersonReferenceEntityImplementation.php index 2a72c5ac..cb2a1333 100644 --- a/tests/Unit/Application/Contracts/ContactPersons/Entity/ContactPersonReferenceEntityImplementation.php +++ b/tests/Unit/Application/Contracts/ContactPersons/Entity/ContactPersonReferenceEntityImplementation.php @@ -16,6 +16,7 @@ use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\ContactPersonInterface; use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\ContactPersonStatus; use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\FullName; +use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\UserAgentInfo; use Bitrix24\SDK\Core\Exceptions\InvalidArgumentException; use Carbon\CarbonImmutable; use Darsyn\IP\Version\Multi as IP; @@ -123,14 +124,10 @@ public function getUpdatedAt(): CarbonImmutable return $this->updatedAt; } - public function changeEmail(?string $email, ?bool $isEmailVerified = null): void + public function changeEmail(?string $email): void { $this->emailVerifiedAt = null; $this->email = $email; - if ($isEmailVerified === true) { - $this->emailVerifiedAt = new CarbonImmutable(); - } - $this->updatedAt = new CarbonImmutable(); } @@ -144,20 +141,21 @@ public function getEmailVerifiedAt(): ?CarbonImmutable return $this->emailVerifiedAt; } + public function isEmailVerified(): bool + { + return $this->emailVerifiedAt instanceof \Carbon\CarbonImmutable; + } + public function markEmailAsVerified(): void { $this->emailVerifiedAt = new CarbonImmutable(); $this->updatedAt = new CarbonImmutable(); } - public function changeMobilePhone(?PhoneNumber $phoneNumber, ?bool $isMobilePhoneVerified = null): void + public function changeMobilePhone(?PhoneNumber $phoneNumber): void { $this->mobilePhoneVerifiedAt = null; $this->mobilePhone = $phoneNumber; - if ($isMobilePhoneVerified === true) { - $this->mobilePhoneVerifiedAt = new CarbonImmutable(); - } - $this->updatedAt = new CarbonImmutable(); } @@ -171,6 +169,11 @@ public function getMobilePhoneVerifiedAt(): ?CarbonImmutable return $this->mobilePhoneVerifiedAt; } + public function isMobilePhoneVerified(): bool + { + return $this->mobilePhoneVerifiedAt instanceof \Carbon\CarbonImmutable; + } + public function markMobilePhoneAsVerified(): void { $this->mobilePhoneVerifiedAt = new CarbonImmutable(); @@ -206,20 +209,15 @@ public function getBitrix24PartnerId(): ?Uuid public function setBitrix24PartnerId(?Uuid $uuid): void { $this->bitrix24PartnerUuid = $uuid; + $this->updatedAt = new CarbonImmutable(); } - public function getUserAgent(): ?string - { - return $this->userAgent; - } - - public function getUserAgentReferer(): ?string - { - return $this->userAgentReferer; - } - - public function getUserAgentIp(): ?IP + public function getUserAgentInfo(): UserAgentInfo { - return $this->userAgentIp; + return new UserAgentInfo( + ip: $this->userAgentIp, + userAgent: $this->userAgent, + referrer: $this->userAgentReferer + ); } } diff --git a/tests/Unit/Application/Contracts/ContactPersons/Entity/UTMsTest.php b/tests/Unit/Application/Contracts/ContactPersons/Entity/UTMsTest.php new file mode 100644 index 00000000..4e5c552a --- /dev/null +++ b/tests/Unit/Application/Contracts/ContactPersons/Entity/UTMsTest.php @@ -0,0 +1,390 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\SDK\Tests\Unit\Application\Contracts\ContactPersons\Entity; + +use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\UTMs; +use Generator; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\TestCase; + +#[CoversClass(UTMs::class)] +class UTMsTest extends TestCase +{ + #[Test] + #[TestDox('constructor should create UTMs object with all parameters')] + public function testConstructorWithAllParameters(): void + { + $utms = new UTMs( + source: 'google', + medium: 'cpc', + campaign: 'spring_sale', + term: 'running shoes', + content: 'banner_blue' + ); + + $this->assertEquals('google', $utms->source); + $this->assertEquals('cpc', $utms->medium); + $this->assertEquals('spring_sale', $utms->campaign); + $this->assertEquals('running shoes', $utms->term); + $this->assertEquals('banner_blue', $utms->content); + } + + #[Test] + #[TestDox('constructor should create UTMs object with default null values')] + public function testConstructorWithDefaultValues(): void + { + $utms = new UTMs(); + + $this->assertNull($utms->source); + $this->assertNull($utms->medium); + $this->assertNull($utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('constructor should create UTMs object with partial parameters')] + public function testConstructorWithPartialParameters(): void + { + $utms = new UTMs( + source: 'facebook', + medium: 'social', + campaign: 'summer_campaign' + ); + + $this->assertEquals('facebook', $utms->source); + $this->assertEquals('social', $utms->medium); + $this->assertEquals('summer_campaign', $utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('fromUrl should parse URL with all UTM parameters')] + public function testFromUrlWithAllParameters(): void + { + $url = 'https://example.com/page?utm_source=google&utm_medium=cpc&utm_campaign=spring_sale&utm_term=running+shoes&utm_content=banner_blue'; + + $utms = UTMs::fromUrl($url); + + $this->assertEquals('google', $utms->source); + $this->assertEquals('cpc', $utms->medium); + $this->assertEquals('spring_sale', $utms->campaign); + $this->assertEquals('running shoes', $utms->term); + $this->assertEquals('banner_blue', $utms->content); + } + + #[Test] + #[TestDox('fromUrl should parse URL with partial UTM parameters')] + public function testFromUrlWithPartialParameters(): void + { + $url = 'https://example.com/page?utm_source=facebook&utm_medium=social'; + + $utms = UTMs::fromUrl($url); + + $this->assertEquals('facebook', $utms->source); + $this->assertEquals('social', $utms->medium); + $this->assertNull($utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('fromUrl should return empty UTMs for URL without query string')] + public function testFromUrlWithoutQueryString(): void + { + $url = 'https://example.com/page'; + + $utms = UTMs::fromUrl($url); + + $this->assertNull($utms->source); + $this->assertNull($utms->medium); + $this->assertNull($utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('fromUrl should return empty UTMs for URL without UTM parameters')] + public function testFromUrlWithoutUtmParameters(): void + { + $url = 'https://example.com/page?param1=value1¶m2=value2'; + + $utms = UTMs::fromUrl($url); + + $this->assertNull($utms->source); + $this->assertNull($utms->medium); + $this->assertNull($utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('fromUrl should parse URL with mixed case UTM parameters')] + public function testFromUrlWithMixedCaseParameters(): void + { + $url = 'https://example.com/page?UTM_SOURCE=Google&utm_MEDIUM=CPC&Utm_Campaign=Spring_Sale'; + + $utms = UTMs::fromUrl($url); + + // UTM parameters should be converted to lowercase + $this->assertEquals('google', $utms->source); + $this->assertEquals('cpc', $utms->medium); + $this->assertEquals('spring_sale', $utms->campaign); + } + + #[Test] + #[TestDox('fromUrl should parse URL with UTM parameters and other query parameters')] + public function testFromUrlWithMixedParameters(): void + { + $url = 'https://example.com/page?id=123&utm_source=twitter&page=2&utm_medium=social&sort=date'; + + $utms = UTMs::fromUrl($url); + + $this->assertEquals('twitter', $utms->source); + $this->assertEquals('social', $utms->medium); + $this->assertNull($utms->campaign); + } + + #[Test] + #[TestDox('fromUrl should handle URL with fragment')] + public function testFromUrlWithFragment(): void + { + $url = 'https://example.com/page?utm_source=linkedin&utm_medium=social#section1'; + + $utms = UTMs::fromUrl($url); + + $this->assertEquals('linkedin', $utms->source); + $this->assertEquals('social', $utms->medium); + } + + #[Test] + #[TestDox('fromUrl should handle URL encoded values')] + public function testFromUrlWithEncodedValues(): void + { + $url = 'https://example.com/page?utm_source=email&utm_campaign=new%20product&utm_content=top%20banner'; + + $utms = UTMs::fromUrl($url); + + $this->assertEquals('email', $utms->source); + $this->assertEquals('new product', $utms->campaign); + $this->assertEquals('top banner', $utms->content); + } + + #[Test] + #[DataProvider('realWorldUrlsProvider')] + #[TestDox('fromUrl should parse real-world URLs')] + public function testFromUrlWithRealWorldUrls( + string $url, + ?string $expectedSource, + ?string $expectedMedium, + ?string $expectedCampaign, + ?string $expectedTerm, + ?string $expectedContent + ): void { + $utms = UTMs::fromUrl($url); + + $this->assertEquals($expectedSource, $utms->source); + $this->assertEquals($expectedMedium, $utms->medium); + $this->assertEquals($expectedCampaign, $utms->campaign); + $this->assertEquals($expectedTerm, $utms->term); + $this->assertEquals($expectedContent, $utms->content); + } + + #[Test] + #[TestDox('fromUrl should handle empty string')] + public function testFromUrlWithEmptyString(): void + { + $url = ''; + + $utms = UTMs::fromUrl($url); + + $this->assertNull($utms->source); + $this->assertNull($utms->medium); + $this->assertNull($utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('fromUrl should handle malformed URLs gracefully')] + public function testFromUrlWithMalformedUrl(): void + { + $url = 'not-a-valid-url'; + + $utms = UTMs::fromUrl($url); + + // Should return empty UTMs object without throwing exception + $this->assertInstanceOf(UTMs::class, $utms); + } + + #[Test] + #[TestDox('fromUrl should handle URL with only query string')] + public function testFromUrlWithOnlyQueryString(): void + { + $url = '?utm_source=google&utm_medium=cpc'; + + $utms = UTMs::fromUrl($url); + + $this->assertEquals('google', $utms->source); + $this->assertEquals('cpc', $utms->medium); + } + + #[Test] + #[TestDox('fromUrl should handle Bitrix24 referrer URL example')] + public function testFromUrlWithBitrix24Example(): void + { + $url = 'https://bitrix24.com/apps/store?utm_source=bx24'; + + $utms = UTMs::fromUrl($url); + + $this->assertEquals('bx24', $utms->source); + $this->assertNull($utms->medium); + $this->assertNull($utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('fromUrl should handle duplicate UTM parameters (last one wins)')] + public function testFromUrlWithDuplicateParameters(): void + { + $url = 'https://example.com/page?utm_source=first&utm_source=second&utm_medium=email'; + + $utms = UTMs::fromUrl($url); + + // parse_str behavior: last value wins + $this->assertEquals('second', $utms->source); + $this->assertEquals('email', $utms->medium); + } + + #[Test] + #[TestDox('fromUrl should handle special characters in UTM values')] + public function testFromUrlWithSpecialCharacters(): void + { + $url = 'https://example.com/page?utm_source=email&utm_campaign=50%25+off&utm_content=red%26blue'; + + $utms = UTMs::fromUrl($url); + + $this->assertEquals('email', $utms->source); + $this->assertEquals('50% off', $utms->campaign); + $this->assertEquals('red&blue', $utms->content); + } + + #[Test] + #[TestDox('UTMs object should be readonly')] + public function testUtmsIsReadonly(): void + { + $utms = new UTMs(source: 'google'); + + $reflectionClass = new \ReflectionClass($utms); + $this->assertTrue($reflectionClass->isReadOnly(), 'UTMs class should be readonly'); + } + + public static function realWorldUrlsProvider(): Generator + { + yield 'Google Ads campaign' => [ + 'https://example.com/product?utm_source=google&utm_medium=cpc&utm_campaign=black_friday_2024&utm_term=buy+shoes&utm_content=ad_variant_a', + 'google', + 'cpc', + 'black_friday_2024', + 'buy shoes', + 'ad_variant_a' + ]; + + yield 'Facebook organic post' => [ + 'https://example.com/blog/article?utm_source=facebook&utm_medium=social&utm_campaign=awareness', + 'facebook', + 'social', + 'awareness', + null, + null + ]; + + yield 'Email newsletter' => [ + 'https://example.com/landing?utm_source=newsletter&utm_medium=email&utm_campaign=monthly_digest&utm_content=header_link', + 'newsletter', + 'email', + 'monthly_digest', + null, + 'header_link' + ]; + + yield 'Twitter post' => [ + 'https://example.com/?utm_source=twitter&utm_medium=social&utm_campaign=product_launch', + 'twitter', + 'social', + 'product_launch', + null, + null + ]; + + yield 'LinkedIn sponsored content' => [ + 'https://example.com/whitepaper?utm_source=linkedin&utm_medium=paid&utm_campaign=b2b_leads&utm_content=whitepaper_cta', + 'linkedin', + 'paid', + 'b2b_leads', + null, + 'whitepaper_cta' + ]; + + yield 'Referral from partner site' => [ + 'https://example.com/signup?utm_source=partner_site&utm_medium=referral&utm_campaign=partnership_q1', + 'partner_site', + 'referral', + 'partnership_q1', + null, + null + ]; + + yield 'YouTube video description' => [ + 'https://example.com/offer?utm_source=youtube&utm_medium=video&utm_campaign=tutorial_series&utm_content=video_description', + 'youtube', + 'video', + 'tutorial_series', + null, + 'video_description' + ]; + + yield 'URL without any UTM parameters' => [ + 'https://example.com/page', + null, + null, + null, + null, + null + ]; + + yield 'URL with only utm_source' => [ + 'https://example.com/page?utm_source=instagram', + 'instagram', + null, + null, + null, + null + ]; + + yield 'Complex URL with path and multiple parameters' => [ + 'https://example.com/category/product/details?id=123&color=red&utm_source=bing&utm_medium=cpc&size=large&utm_campaign=summer_sale', + 'bing', + 'cpc', + 'summer_sale', + null, + null + ]; + } +} diff --git a/tests/Unit/Application/Contracts/ContactPersons/Entity/UserAgentInfoTest.php b/tests/Unit/Application/Contracts/ContactPersons/Entity/UserAgentInfoTest.php new file mode 100644 index 00000000..01789545 --- /dev/null +++ b/tests/Unit/Application/Contracts/ContactPersons/Entity/UserAgentInfoTest.php @@ -0,0 +1,449 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\SDK\Tests\Unit\Application\Contracts\ContactPersons\Entity; + +use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\UserAgentInfo; +use Bitrix24\SDK\Application\Contracts\ContactPersons\Entity\UTMs; +use Darsyn\IP\Version\Multi as IP; +use Generator; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\TestCase; + +#[CoversClass(UserAgentInfo::class)] +class UserAgentInfoTest extends TestCase +{ + #[Test] + #[TestDox('constructor should create UserAgentInfo with all parameters')] + public function testConstructorWithAllParameters(): void + { + $ip = IP::factory('192.168.1.1'); + $userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'; + $referrer = 'https://google.com/search?q=test'; + $fingerprint = 'unique-browser-fingerprint-12345'; + + $userAgentInfo = new UserAgentInfo( + ip: $ip, + userAgent: $userAgent, + referrer: $referrer, + fingerprint: $fingerprint + ); + + $this->assertSame($ip, $userAgentInfo->ip); + $this->assertEquals($userAgent, $userAgentInfo->userAgent); + $this->assertEquals($referrer, $userAgentInfo->referrer); + $this->assertEquals($fingerprint, $userAgentInfo->fingerprint); + } + + #[Test] + #[TestDox('constructor should create UserAgentInfo with only required parameter')] + public function testConstructorWithOnlyIp(): void + { + $ip = IP::factory('10.0.0.1'); + + $userAgentInfo = new UserAgentInfo(ip: $ip); + + $this->assertSame($ip, $userAgentInfo->ip); + $this->assertNull($userAgentInfo->userAgent); + $this->assertNull($userAgentInfo->referrer); + $this->assertNull($userAgentInfo->fingerprint); + } + + #[Test] + #[TestDox('constructor should create UserAgentInfo with null IP')] + public function testConstructorWithNullIp(): void + { + $userAgent = 'Mozilla/5.0'; + + $userAgentInfo = new UserAgentInfo( + ip: null, + userAgent: $userAgent + ); + + $this->assertNull($userAgentInfo->ip); + $this->assertEquals($userAgent, $userAgentInfo->userAgent); + } + + #[Test] + #[TestDox('constructor should handle IPv4 addresses')] + public function testConstructorWithIpv4(): void + { + $ip = IP::factory('192.168.0.1'); + + $userAgentInfo = new UserAgentInfo(ip: $ip); + + $this->assertInstanceOf(IP::class, $userAgentInfo->ip); + $this->assertSame($ip, $userAgentInfo->ip); + } + + #[Test] + #[TestDox('constructor should handle IPv6 addresses')] + public function testConstructorWithIpv6(): void + { + $ip = IP::factory('2001:0db8:85a3:0000:0000:8a2e:0370:7334'); + + $userAgentInfo = new UserAgentInfo(ip: $ip); + + $this->assertInstanceOf(IP::class, $userAgentInfo->ip); + } + + #[Test] + #[TestDox('constructor should handle localhost IP')] + public function testConstructorWithLocalhostIp(): void + { + $ip = IP::factory('127.0.0.1'); + + $userAgentInfo = new UserAgentInfo(ip: $ip); + + $this->assertInstanceOf(IP::class, $userAgentInfo->ip); + $this->assertSame($ip, $userAgentInfo->ip); + } + + #[Test] + #[DataProvider('userAgentStringsProvider')] + #[TestDox('constructor should handle various user agent strings')] + public function testConstructorWithVariousUserAgents(string $userAgent): void + { + $userAgentInfo = new UserAgentInfo( + ip: null, + userAgent: $userAgent + ); + + $this->assertEquals($userAgent, $userAgentInfo->userAgent); + } + + #[Test] + #[TestDox('getUTMs should return empty UTMs when referrer is null')] + public function testGetUTMsWithNullReferrer(): void + { + $userAgentInfo = new UserAgentInfo( + ip: IP::factory('192.168.1.1'), + userAgent: 'Mozilla/5.0' + ); + + $utms = $userAgentInfo->getUTMs(); + + $this->assertInstanceOf(UTMs::class, $utms); + $this->assertNull($utms->source); + $this->assertNull($utms->medium); + $this->assertNull($utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('getUTMs should parse UTMs from referrer with all parameters')] + public function testGetUTMsWithFullReferrer(): void + { + $referrer = 'https://example.com/page?utm_source=google&utm_medium=cpc&utm_campaign=spring_sale&utm_term=shoes&utm_content=banner'; + + $userAgentInfo = new UserAgentInfo( + ip: null, + referrer: $referrer + ); + + $utms = $userAgentInfo->getUTMs(); + + $this->assertEquals('google', $utms->source); + $this->assertEquals('cpc', $utms->medium); + $this->assertEquals('spring_sale', $utms->campaign); + $this->assertEquals('shoes', $utms->term); + $this->assertEquals('banner', $utms->content); + } + + #[Test] + #[TestDox('getUTMs should parse UTMs from referrer with partial parameters')] + public function testGetUTMsWithPartialReferrer(): void + { + $referrer = 'https://facebook.com/post?utm_source=facebook&utm_medium=social'; + + $userAgentInfo = new UserAgentInfo( + ip: null, + referrer: $referrer + ); + + $utms = $userAgentInfo->getUTMs(); + + $this->assertEquals('facebook', $utms->source); + $this->assertEquals('social', $utms->medium); + $this->assertNull($utms->campaign); + $this->assertNull($utms->term); + $this->assertNull($utms->content); + } + + #[Test] + #[TestDox('getUTMs should return empty UTMs when referrer has no UTM parameters')] + public function testGetUTMsWithReferrerWithoutUtm(): void + { + $referrer = 'https://example.com/page?id=123&page=2'; + + $userAgentInfo = new UserAgentInfo( + ip: null, + referrer: $referrer + ); + + $utms = $userAgentInfo->getUTMs(); + + $this->assertNull($utms->source); + $this->assertNull($utms->medium); + $this->assertNull($utms->campaign); + } + + #[Test] + #[TestDox('getUTMs should handle Bitrix24 referrer example')] + public function testGetUTMsWithBitrix24Referrer(): void + { + $referrer = 'https://bitrix24.com/apps/store?utm_source=bx24'; + + $userAgentInfo = new UserAgentInfo( + ip: IP::factory('192.168.1.1'), + userAgent: 'Mozilla/5.0', + referrer: $referrer + ); + + $utms = $userAgentInfo->getUTMs(); + + $this->assertEquals('bx24', $utms->source); + $this->assertNull($utms->medium); + $this->assertNull($utms->campaign); + } + + #[Test] + #[DataProvider('referrerWithUTMsProvider')] + #[TestDox('getUTMs should parse various referrer URLs with UTMs')] + public function testGetUTMsWithVariousReferrers( + string $referrer, + ?string $expectedSource, + ?string $expectedMedium, + ?string $expectedCampaign + ): void { + $userAgentInfo = new UserAgentInfo( + ip: null, + referrer: $referrer + ); + + $utms = $userAgentInfo->getUTMs(); + + $this->assertEquals($expectedSource, $utms->source); + $this->assertEquals($expectedMedium, $utms->medium); + $this->assertEquals($expectedCampaign, $utms->campaign); + } + + #[Test] + #[TestDox('UserAgentInfo should be readonly')] + public function testUserAgentInfoIsReadonly(): void + { + $userAgentInfo = new UserAgentInfo(ip: null); + + $reflectionClass = new \ReflectionClass($userAgentInfo); + $this->assertTrue($reflectionClass->isReadOnly(), 'UserAgentInfo class should be readonly'); + } + + #[Test] + #[TestDox('constructor should handle complete real-world scenario')] + public function testConstructorWithCompleteRealWorldScenario(): void + { + $ip = IP::factory('203.0.113.42'); + $userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1'; + $referrer = 'https://google.com/search?q=bitrix24&utm_source=google&utm_medium=organic'; + $fingerprint = 'fp_a1b2c3d4e5f6'; + + $userAgentInfo = new UserAgentInfo( + ip: $ip, + userAgent: $userAgent, + referrer: $referrer, + fingerprint: $fingerprint + ); + + // Verify all properties + $this->assertInstanceOf(IP::class, $userAgentInfo->ip); + $this->assertSame($ip, $userAgentInfo->ip); + $this->assertEquals($userAgent, $userAgentInfo->userAgent); + $this->assertEquals($referrer, $userAgentInfo->referrer); + $this->assertEquals($fingerprint, $userAgentInfo->fingerprint); + + // Verify UTMs extracted from referrer + $utms = $userAgentInfo->getUTMs(); + $this->assertEquals('google', $utms->source); + $this->assertEquals('organic', $utms->medium); + } + + #[Test] + #[TestDox('getUTMs should return same UTMs object structure on multiple calls')] + public function testGetUTMsConsistency(): void + { + $referrer = 'https://example.com?utm_source=test&utm_medium=email'; + + $userAgentInfo = new UserAgentInfo( + ip: null, + referrer: $referrer + ); + + $utms1 = $userAgentInfo->getUTMs(); + $utms2 = $userAgentInfo->getUTMs(); + + // Different objects but same values + $this->assertNotSame($utms1, $utms2); + $this->assertEquals($utms1->source, $utms2->source); + $this->assertEquals($utms1->medium, $utms2->medium); + } + + #[Test] + #[TestDox('constructor should handle empty string values')] + public function testConstructorWithEmptyStrings(): void + { + $userAgentInfo = new UserAgentInfo( + ip: null, + userAgent: '', + referrer: '', + fingerprint: '' + ); + + $this->assertEquals('', $userAgentInfo->userAgent); + $this->assertEquals('', $userAgentInfo->referrer); + $this->assertEquals('', $userAgentInfo->fingerprint); + } + + #[Test] + #[TestDox('getUTMs should handle empty referrer string')] + public function testGetUTMsWithEmptyReferrer(): void + { + $userAgentInfo = new UserAgentInfo( + ip: null, + referrer: '' + ); + + $utms = $userAgentInfo->getUTMs(); + + $this->assertInstanceOf(UTMs::class, $utms); + $this->assertNull($utms->source); + } + + #[Test] + #[TestDox('constructor should handle very long user agent strings')] + public function testConstructorWithLongUserAgent(): void + { + $longUserAgent = str_repeat('Mozilla/5.0 ', 100); + + $userAgentInfo = new UserAgentInfo( + ip: null, + userAgent: $longUserAgent + ); + + $this->assertEquals($longUserAgent, $userAgentInfo->userAgent); + } + + #[Test] + #[TestDox('constructor should handle special characters in fingerprint')] + public function testConstructorWithSpecialCharactersInFingerprint(): void + { + $fingerprint = 'fp_!@#$%^&*()_+-=[]{}|;:,.<>?'; + + $userAgentInfo = new UserAgentInfo( + ip: null, + fingerprint: $fingerprint + ); + + $this->assertEquals($fingerprint, $userAgentInfo->fingerprint); + } + + public static function userAgentStringsProvider(): Generator + { + yield 'Chrome on Windows' => [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ]; + + yield 'Firefox on macOS' => [ + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/121.0' + ]; + + yield 'Safari on iPhone' => [ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1' + ]; + + yield 'Edge on Windows' => [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0' + ]; + + yield 'Android Chrome' => [ + 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36' + ]; + + yield 'Opera on Linux' => [ + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0' + ]; + + yield 'Bot user agent' => [ + 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' + ]; + + yield 'Empty user agent' => [ + '' + ]; + } + + public static function referrerWithUTMsProvider(): Generator + { + yield 'Google search with organic' => [ + 'https://google.com/search?q=bitrix24&utm_source=google&utm_medium=organic', + 'google', + 'organic', + null + ]; + + yield 'Facebook post' => [ + 'https://facebook.com/post/123?utm_source=facebook&utm_medium=social&utm_campaign=product_launch', + 'facebook', + 'social', + 'product_launch' + ]; + + yield 'Email newsletter' => [ + 'https://example.com/landing?utm_source=newsletter&utm_medium=email&utm_campaign=weekly_digest', + 'newsletter', + 'email', + 'weekly_digest' + ]; + + yield 'Twitter link' => [ + 'https://example.com/?utm_source=twitter&utm_medium=social', + 'twitter', + 'social', + null + ]; + + yield 'LinkedIn sponsored' => [ + 'https://example.com/whitepaper?utm_source=linkedin&utm_medium=paid&utm_campaign=b2b_campaign', + 'linkedin', + 'paid', + 'b2b_campaign' + ]; + + yield 'No UTM parameters' => [ + 'https://example.com/page?id=123', + null, + null, + null + ]; + + yield 'Direct visit (no query)' => [ + 'https://example.com/', + null, + null, + null + ]; + } +}