diff --git a/tests/PHPUnit/Fixtures/UITestFixture.php b/tests/PHPUnit/Fixtures/UITestFixture.php index 251975a2a8d..54ff545aa2d 100644 --- a/tests/PHPUnit/Fixtures/UITestFixture.php +++ b/tests/PHPUnit/Fixtures/UITestFixture.php @@ -312,6 +312,7 @@ public static function createOverlayTestSite($idSite = 3) $files = [ 'index.html', 'opt-out.php', + 'user-id-visitor-id.php', 'page-1.html', 'page-2.html', 'page-3.html', diff --git a/tests/PHPUnit/Integration/Tracker/UserIdVisitorIdTest.php b/tests/PHPUnit/Integration/Tracker/UserIdVisitorIdTest.php new file mode 100644 index 00000000000..74a09e064e3 --- /dev/null +++ b/tests/PHPUnit/Integration/Tracker/UserIdVisitorIdTest.php @@ -0,0 +1,807 @@ +testEnv = static::$fixture->getTestEnvironment(); + $this->trackerEventTsIterator = Date::factory(self::FIRST_VISIT_TIME)->getTimestamp(); + } + + private function trackPageview(\MatomoTracker $tracker, $url) + { + $tracker->setForceVisitDateTime($this->trackerEventTsIterator++); + $response = $tracker->doTrackPageView($url); + Fixture::checkResponse($response); + } + + private function trackAction(\MatomoTracker $tracker, $action) + { + $tracker->setForceVisitDateTime($this->trackerEventTsIterator++); + $response = $tracker->doTrackAction($action, 'link'); + Fixture::checkResponse($response); + } + + private function logInUser(\MatomoTracker $tracker) + { + $tracker->setUserId('user-1'); + } + + private function logOutUser(\MatomoTracker $tracker) + { + $tracker->setUserId(false); + } + + private function configureUserIdOverwritesVisitorId(bool $userIdOverwritesVisitorId) + { + Config::getInstance()->Tracker['enable_userid_overwrites_visitorid'] = $userIdOverwritesVisitorId ? 1 : 0; + } + + private function getTracker() + { + $tracker = Fixture::getTracker(1, self::FIRST_VISIT_TIME, $defaultInit = true, $useLocalTracker = true); + $tracker->setTokenAuth(Fixture::getTokenAuth()); + + // properties that cannot be changed on next action + $tracker->setUserAgent(self::TEST_USER_AGENT); + $tracker->setBrowserLanguage(self::TEST_BROWSER_LANGUAGE); + + // properties that can be changed on next action + $tracker->setCountry(self::TEST_COUNTRY); + $tracker->setRegion(self::TEST_REGION); + + return $tracker; + } + + private function getTrackerForAlternateDevice() + { + $tracker = $this->getTracker(); + + $tracker->setUserAgent(self::CHANGED_USER_AGENT); + $tracker->setBrowserLanguage(self::CHANGED_BROWSER_LANGUAGE); + $tracker->setCountry(self::CHANGED_COUNTRY); + $tracker->setRegion(self::CHANGED_REGION); + + return $tracker; + } + + public function getUserIdOverwritesVisitorIdSetting(): iterable + { + yield "enable_userid_overwrites_visitorid is enabled" => [ + true + ]; + + yield "enable_userid_overwrites_visitorid is disabled" => [ + false + ]; + } + + /** + * user does not log in during a visit, all actions are assigned to a single visitor ID + * + * @dataProvider getUserIdOverwritesVisitorIdSetting + */ + public function testUserDoesNotLogInDuringVisit(bool $userIdOverwritesVisitorId) + { + $this->configureUserIdOverwritesVisitorId($userIdOverwritesVisitorId); + $tracker = $this->getTracker(); + + $this->trackPageview($tracker, 'page-1'); + $this->assertCounts([1], 1); + + $this->trackPageview($tracker, 'page-2'); + $this->assertCounts([2], 1); + + $this->trackAction($tracker, 'action-1'); + $this->assertCounts([3], 1); + + // force a new visit for the same visitor + $tracker->setForceNewVisit(); + $this->trackPageview($tracker, 'page-3'); + $this->assertCounts([3, 1], 1); + + $this->trackAction($tracker, 'action-2'); + $this->assertCounts([3, 2], 1); + } + + /** + * user logs in during a visit, custom User ID is only provided for the log in action + * user id replaces visitor id and there is still only one distinct value, but is different + * + * @dataProvider getUserIdOverwritesVisitorIdSetting + */ + public function testUserLogsInDuringVisit(bool $userIdOverwritesVisitorId) + { + $this->configureUserIdOverwritesVisitorId($userIdOverwritesVisitorId); + $tracker = $this->getTracker(); + + $this->trackPageview($tracker, 'page-1'); + $this->assertCounts([1], 1); + + $this->trackPageview($tracker, 'page-2'); + $this->assertCounts([2], 1); + + $this->trackAction($tracker, 'action-1'); + $this->assertCounts([3], 1); + + $visitorId1 = $this->getVisitProperty('idvisitor', 1); + + // track second action with user id + $this->logInUser($tracker); + $this->trackAction($tracker, 'log-in'); + $this->assertCounts([4], 1); + + $visitorId2 = $this->getVisitProperty('idvisitor', 1); + if ($userIdOverwritesVisitorId) { + // expect changed visitor id + $this->assertNotEquals($visitorId1, $visitorId2); + } else { + $this->assertEquals($visitorId1, $visitorId2); + } + + $this->trackAction($tracker, 'action-2'); + $this->assertCounts([5], 1); + + $this->trackPageview($tracker, 'page-3'); + $this->assertCounts([6], 1); + } + + /** + * @dataProvider getUserIdOverwritesVisitorIdSetting + */ + public function testUserLogsInDuringVisitWithoutActions(bool $userIdOverwritesVisitorId) + { + $this->configureUserIdOverwritesVisitorId($userIdOverwritesVisitorId); + $tracker = $this->getTracker(); + + $this->trackPageview($tracker, 'page-1'); + $this->assertCounts([1], 1); + + $this->trackPageview($tracker, 'page-2'); + $this->assertCounts([2], 1); + + $visitorId1 = $this->getVisitProperty('idvisitor', 1); + + // track second action with user id + $this->logInUser($tracker); + $this->trackPageview($tracker, 'page-3'); + $this->assertCounts([3], 1); + + $visitorId2 = $this->getVisitProperty('idvisitor', 1); + if ($userIdOverwritesVisitorId) { + // expect changed visitor id + $this->assertNotEquals($visitorId1, $visitorId2); + } else { + $this->assertEquals($visitorId1, $visitorId2); + } + + $this->trackPageview($tracker, 'page-4'); + $this->assertCounts([4], 1); + } + + /** + * user logs in and out during a visit, custom User ID is only provided for the log in action + * user id replaces visitor id and there is still only one distinct value, but is different + * + * @dataProvider getUserIdOverwritesVisitorIdSetting + */ + public function testUserLogsInAndOutDuringVisit(bool $userIdOverwritesVisitorId) + { + $this->configureUserIdOverwritesVisitorId($userIdOverwritesVisitorId); + $tracker = $this->getTracker(); + + $this->trackPageview($tracker, 'page-1'); + $this->assertCounts([1], 1); + + $this->trackPageview($tracker, 'page-2'); + $this->assertCounts([2], 1); + + $this->trackAction($tracker, 'action-1'); + $this->assertCounts([3], 1); + + $visitorId1 = $this->getVisitProperty('idvisitor', 1); + + // track second action with user id + $this->logInUser($tracker); + $this->trackAction($tracker, 'log-in'); + $this->assertCounts([4], 1); + + $visitorId2 = $this->getVisitProperty('idvisitor', 1); + if ($userIdOverwritesVisitorId) { + // expect changed visitor id + $this->assertNotEquals($visitorId1, $visitorId2); + } else { + $this->assertEquals($visitorId1, $visitorId2); + } + + $this->trackAction($tracker, 'action-2'); + $this->assertCounts([5], 1); + + $this->trackPageview($tracker, 'page-3'); + $this->assertCounts([6], 1); + + // log out and de-set user id + $this->logOutUser($tracker); + $this->trackAction($tracker, 'log-out'); + $this->assertCounts([7], 1); + + $visitorId3 = $this->getVisitProperty('idvisitor', 1); + $this->assertEquals($visitorId1, $visitorId3); + if ($userIdOverwritesVisitorId) { + // expect original visitor id after logging out + $this->assertNotEquals($visitorId2, $visitorId3); + } else { + $this->assertEquals($visitorId2, $visitorId3); + } + + $this->trackAction($tracker, 'action-3'); + $this->assertCounts([8], 1); + } + + /** + * @dataProvider getUserIdOverwritesVisitorIdSetting + */ + public function testUserLogsInAndOutDuringVisitWithoutActions(bool $userIdOverwritesVisitorId) + { + $this->configureUserIdOverwritesVisitorId($userIdOverwritesVisitorId); + $tracker = $this->getTracker(); + + $this->trackPageview($tracker, 'page-1'); + $this->assertCounts([1], 1); + + $this->trackPageview($tracker, 'page-2'); + $this->assertCounts([2], 1); + + $visitorId1 = $this->getVisitProperty('idvisitor', 1); + + // track second action with user id + $this->logInUser($tracker); + $this->trackPageview($tracker, 'page-3'); + $this->assertCounts([3], 1); + + $visitorId2 = $this->getVisitProperty('idvisitor', 1); + if ($userIdOverwritesVisitorId) { + // expect changed visitor id + $this->assertNotEquals($visitorId1, $visitorId2); + } else { + $this->assertEquals($visitorId1, $visitorId2); + } + + $this->trackPageview($tracker, 'page-4'); + $this->assertCounts([4], 1); + + // log out and de-set user id + $this->logOutUser($tracker); + $this->trackPageview($tracker, 'page-5'); + $this->assertCounts([5], 1); + + $visitorId3 = $this->getVisitProperty('idvisitor', 1); + $this->assertEquals($visitorId1, $visitorId3); + if ($userIdOverwritesVisitorId) { + // expect original visitor id after logging out + $this->assertNotEquals($visitorId2, $visitorId3); + } else { + $this->assertEquals($visitorId2, $visitorId3); + } + + $this->trackPageview($tracker, 'page-6'); + $this->assertCounts([6], 1); + } + + /** + * @dataProvider getUserIdOverwritesVisitorIdSetting + */ + public function testUserLoggedInOnMultipleDevices(bool $userIdOverwritesVisitorId) + { + $this->configureUserIdOverwritesVisitorId($userIdOverwritesVisitorId); + $trackerDevice1 = $this->getTracker(); + + $this->trackPageview($trackerDevice1, 'page-1'); + $this->trackPageview($trackerDevice1, 'page-2'); + $this->trackAction($trackerDevice1, 'action-1'); + $this->logInUser($trackerDevice1); + $this->trackAction($trackerDevice1, 'log-in'); + $this->assertCounts([4], 1); + + $trackerDevice2 = $this->getTrackerForAlternateDevice(); + + $this->trackPageview($trackerDevice2, 'page-3'); + $this->trackPageview($trackerDevice2, 'page-4'); + $this->trackAction($trackerDevice2, 'action-2'); + $this->logInUser($trackerDevice2); + $this->trackAction($trackerDevice2, 'log-in'); + + if ($userIdOverwritesVisitorId) { + // TODO: It may be unexpected that the last action of the second visit(or) is actually attributed to the first + // visit(or). This is caused by the way how existing visits are looked up. + // After the login a userid is provided. Therefor Matomo tracker looks for an existing visit by prioritizing the + // userid (=visitorid). As a visit is found it will be resumed - instead of updating the actually running one + + $this->assertCounts([5, 3], 2); + } else { + $this->assertCounts([4, 4], 2); + } + $this->assertVisitorIdsCount(2); + + // multiple devices, multiple config IDs + $this->assertConfigIdsCount(2); + } + + /** + * @dataProvider getUserIdOverwritesVisitorIdSetting + */ + public function testUserLoggedInOnMultipleDevicesWithoutActions(bool $userIdOverwritesVisitorId) + { + $this->configureUserIdOverwritesVisitorId($userIdOverwritesVisitorId); + $trackerDevice1 = $this->getTracker(); + + $this->trackPageview($trackerDevice1, 'page-1'); + $this->trackPageview($trackerDevice1, 'page-2'); + $this->logInUser($trackerDevice1); + $this->trackPageview($trackerDevice1, 'page-3'); + $this->assertCounts([3], 1); + + $trackerDevice2 = $this->getTrackerForAlternateDevice(); + + $this->trackPageview($trackerDevice2, 'page-4'); + $this->trackPageview($trackerDevice2, 'page-5'); + $this->logInUser($trackerDevice2); + $this->trackPageview($trackerDevice2, 'page-6'); + + if ($userIdOverwritesVisitorId) { + // TODO: It may be unexpected that the last action of the second visit(or) is actually attributed to the first + // visit(or). This is caused by the way how existing visits are looked up. + // After the login a userid is provided. Therefor Matomo tracker looks for an existing visit by prioritizing the + // userid (=visitorid). As a visit is found it will be resumed - instead of updating the actually running one + + $this->assertCounts([4, 2], 2); + } else { + $this->assertCounts([3, 3], 2); + } + $this->assertVisitorIdsCount(2); + + // multiple devices, multiple config IDs + $this->assertConfigIdsCount(2); + } + + /** + * @dataProvider getUserIdOverwritesVisitorIdSetting + */ + public function testUserLogsInAndOutMultipleTimes(bool $userIdOverwritesVisitorId) + { + $this->configureUserIdOverwritesVisitorId($userIdOverwritesVisitorId); + $tracker = $this->getTracker(); + + $this->trackPageview($tracker, 'page-1'); + $this->trackPageview($tracker, 'page-2'); + $this->trackAction($tracker, 'action-1'); + $this->logInUser($tracker); + $this->trackAction($tracker, 'log-in'); + $visitorId1 = $this->getVisitProperty('idvisitor', 1); + + $this->assertCounts([4], 1); + + $this->trackAction($tracker, 'action-2'); + $this->trackPageview($tracker, 'page-3'); + + $this->assertCounts([6], 1); + + $this->logOutUser($tracker); + $this->trackAction($tracker, 'log-out'); + $visitorId2 = $this->getVisitProperty('idvisitor', 1); + + $this->assertCounts([7], 1, 1); + + // force new visit and log in + // By not creating a new tracker instance here, the random visitorid won't change + // This is similar to using cookies in browser, where visitorid would be persisted + $tracker->setForceNewVisit(); + + $this->trackAction($tracker, 'action-3'); + $this->trackPageview($tracker, 'page-4'); + $this->logInUser($tracker); + $this->trackAction($tracker, 'log-in'); + $visitorId3 = $this->getVisitProperty('idvisitor', 2); + + if ($userIdOverwritesVisitorId) { + $this->assertCounts([7, 3], 2); + } else { + $this->assertCounts([7, 3], 1); + } + + $this->trackAction($tracker, 'action-4'); + $this->trackPageview($tracker, 'page-5'); + + if ($userIdOverwritesVisitorId) { + $this->assertCounts([7, 5], 2); + } else { + $this->assertCounts([7, 5], 1); + } + + $this->logOutUser($tracker); + $this->trackAction($tracker, 'log-out'); + $visitorId4 = $this->getVisitProperty('idvisitor', 2); + if ($userIdOverwritesVisitorId) { + // TODO: It may be unexpected that the last action of the second visit(or) is actually attributed to the first + // visit(or). This is caused by the way how existing visits are looked up. + // After the login a userid is provided. Therefor Matomo tracker looks for an existing visit by prioritizing the + // userid (=visitorid). As a visit is found it will be resumed - instead of updating the actually running one + + $this->assertCounts([8, 5], 2); + } else { + $this->assertCounts([7, 6], 1); + } + + if ($userIdOverwritesVisitorId) { + $this->assertNotEquals($visitorId1, $visitorId2); + $this->assertEquals($visitorId1, $visitorId3); + $this->assertEquals($visitorId1, $visitorId4); + } else { + $this->assertEquals($visitorId1, $visitorId2); + $this->assertEquals($visitorId1, $visitorId3); + $this->assertEquals($visitorId3, $visitorId4); + } + + $this->assertUserIdsCount(1); + } + + /** + * @dataProvider getUserIdOverwritesVisitorIdSetting + */ + public function testUserLogsInAndOutMultipleTimesWithInitialVisitBeforeTimeFrame(bool $userIdOverwritesVisitorId) + { + $this->configureUserIdOverwritesVisitorId($userIdOverwritesVisitorId); + $tracker = $this->getTracker(); + + $this->trackPageview($tracker, 'page-1'); + $this->trackPageview($tracker, 'page-2'); + $this->trackAction($tracker, 'action-1'); + $this->logInUser($tracker); + $this->trackAction($tracker, 'log-in'); + $visitorId1 = $this->getVisitProperty('idvisitor', 1); + + $this->assertCounts([4], 1); + + $this->trackAction($tracker, 'action-2'); + $this->trackPageview($tracker, 'page-3'); + + $this->assertCounts([6], 1); + + $this->logOutUser($tracker); + $this->trackAction($tracker, 'log-out'); + $visitorId2 = $this->getVisitProperty('idvisitor', 1); + + $this->assertCounts([7], 1, 1); + + // force new visit after end of default visit length and log in + // By not creating a new tracker instance here, the random visitorid won't change + // This is similar to using cookies in browser, where visitorid would be persisted + $this->trackerEventTsIterator += Config::getInstance()->Tracker['visit_standard_length'] + 1; + $tracker->setForceNewVisit(); + + $this->trackAction($tracker, 'action-3'); + $this->trackPageview($tracker, 'page-4'); + $this->logInUser($tracker); + $this->trackAction($tracker, 'log-in'); + $visitorId3 = $this->getVisitProperty('idvisitor', 2); + + if ($userIdOverwritesVisitorId) { + $this->assertCounts([7, 3], 2); + } else { + $this->assertCounts([7, 3], 1); + } + + $this->trackAction($tracker, 'action-4'); + $this->trackPageview($tracker, 'page-5'); + + if ($userIdOverwritesVisitorId) { + $this->assertCounts([7, 5], 2); + } else { + $this->assertCounts([7, 5], 1); + } + + $this->logOutUser($tracker); + $this->trackAction($tracker, 'log-out'); + if ($userIdOverwritesVisitorId) { + // TODO: It may be unexpected that the last action of the second visit(or) creates a new visit + // This is caused by the way how existing visits are looked up. + // After the login a userid is provided. Therefor Matomo tracker looks for an existing visit by prioritizing the + // userid (=visitorid). As no one can be found it creates a new one - instead of updating the actually running one + + $visitorId4 = $this->getVisitProperty('idvisitor', 3); + $this->assertCounts([7, 5, 1], 2); + } else { + $visitorId4 = $this->getVisitProperty('idvisitor', 2); + $this->assertCounts([7, 6], 1); + } + + if ($userIdOverwritesVisitorId) { + $this->assertNotEquals($visitorId1, $visitorId2); + $this->assertEquals($visitorId1, $visitorId3); + $this->assertNotEquals($visitorId1, $visitorId4); + $this->assertEquals($visitorId2, $visitorId4); + } else { + $this->assertEquals($visitorId1, $visitorId2); + $this->assertEquals($visitorId2, $visitorId3); + $this->assertEquals($visitorId3, $visitorId4); + } + + $this->assertUserIdsCount(1); + } + + /** + * @dataProvider getUserIdOverwritesVisitorIdSetting + */ + public function testNewVisitWithLoginAndLogoutCombination(bool $userIdOverwritesVisitorId) + { + $this->configureUserIdOverwritesVisitorId($userIdOverwritesVisitorId); + // track a first visit (without login) + $tracker = $this->getTracker(); + + $this->trackPageview($tracker, 'page-1'); + $this->trackPageview($tracker, 'page-2'); + + $this->assertCounts([2], 1); + + // force a new visit of the same visitor (with login and logout) + $tracker->setForceNewVisit(); + $this->trackPageview($tracker, 'page-1'); + + $this->assertCounts([2, 1], 1); + $visitorId1 = $this->getVisitProperty('idvisitor', 2); + + $this->logInUser($tracker); + $this->trackPageview($tracker, 'page-3'); + + $visitorId2 = $this->getVisitProperty('idvisitor', 2); + + if ($userIdOverwritesVisitorId) { + $this->assertNotEquals($visitorId1, $visitorId2); + // logging in causes the visitor id to change, therefore we afterwards have 2 different + $this->assertCounts([2, 2], 2); + } else { + $this->assertEquals($visitorId1, $visitorId2); + // forcing a visit so we have two visits with 2 actions each + $this->assertCounts([2, 2], 1); + } + + $this->logOutUser($tracker); + $this->trackPageview($tracker, 'page-5'); + + if ($userIdOverwritesVisitorId) { + // TODO: It may be unexpected that the last action of the second visit is actually attributed to the first + // visit. This is caused by the way how existing visits are looked up. + // After the login a userid is provided. Therefor Matomo tracker updates the visitorid of the second visit, + // while the visitorid of the first visit remains. As the php tracker still has the original visitorid set, + // after logout this visitorid will be used again to look for an existing visit. + // This causes the last action to be attributed to the first visit. + + $this->assertCounts([3, 2], 2); + } else { + $this->assertCounts([2, 3], 1); + } + } + + /** + * @dataProvider getUserIdOverwritesVisitorIdSetting + */ + public function testUserLogsInAndOutMultipleTimesWithoutActions(bool $userIdOverwritesVisitorId) + { + $this->configureUserIdOverwritesVisitorId($userIdOverwritesVisitorId); + $tracker = $this->getTracker(); + + $this->trackPageview($tracker, 'page-1'); + $this->trackPageview($tracker, 'page-2'); + $this->logInUser($tracker); + $this->trackPageview($tracker, 'page-3'); + $visitorId1 = $this->getVisitProperty('idvisitor', 1); + + $this->assertCounts([3], 1); + + $this->trackPageview($tracker, 'page-4'); + + $this->assertCounts([4], 1); + + $this->logOutUser($tracker); + $this->trackPageview($tracker, 'page-5'); + $visitorId2 = $this->getVisitProperty('idvisitor', 1); + + $this->assertCounts([5], 1, 1); + + // force new visit and log in + // By creating a new tracker instance here, the random visitorid changes + // This is similar to removing cookies in browser, where visitorid otherwise would be persisted + $tracker = $this->getTracker(); + $tracker->setForceNewVisit(); + + $this->trackPageview($tracker, 'page-6'); + $this->logInUser($tracker); + $this->trackPageview($tracker, 'page-7'); + $visitorId3 = $this->getVisitProperty('idvisitor', 2); + + $this->assertCounts([5, 2], 2); + + $this->trackPageview($tracker, 'page-5'); + $this->assertCounts([5, 3], 2); + + $this->logOutUser($tracker); + $this->trackPageview($tracker, 'page-6'); + $visitorId4 = $this->getVisitProperty('idvisitor', 2); + $this->assertCounts([5, 4], 2); + + if ($userIdOverwritesVisitorId) { + $this->assertNotEquals($visitorId1, $visitorId2); + $this->assertEquals($visitorId1, $visitorId3); + // since we forced a new visit, the visitor id after second log out is different + $this->assertNotEquals($visitorId2, $visitorId4); + } else { + $this->assertEquals($visitorId1, $visitorId2); + $this->assertNotEquals($visitorId1, $visitorId3); + // since we forced a new visit, the visitor id after second log out is different + $this->assertEquals($visitorId3, $visitorId4); + } + + $this->assertUserIdsCount(1); + } + + /** + * @dataProvider getUserIdOverwritesVisitorIdSetting + */ + public function testNewVisitTriggeredByInactivity(bool $userIdOverwritesVisitorId) + { + $this->configureUserIdOverwritesVisitorId($userIdOverwritesVisitorId); + $tracker = $this->getTracker(); + + $this->trackPageview($tracker, 'page-1'); + $this->trackPageview($tracker, 'page-2'); + $this->trackAction($tracker, 'action-1'); + $this->logInUser($tracker); + $this->trackAction($tracker, 'log-in'); + $visitorId1 = $this->getVisitProperty('idvisitor', 1); + + // move time beyond default visit length + $this->trackerEventTsIterator += Config::getInstance()->Tracker['visit_standard_length'] + 1; + + $this->trackPageview($tracker, 'page-3'); + $this->trackPageview($tracker, 'page-4'); + $this->trackAction($tracker, 'action-5'); + $visitorId2 = $this->getVisitProperty('idvisitor', 2); + + $this->assertCounts([4, 3], 1, 1); + $this->assertEquals($visitorId1, $visitorId2); + + // move time beyond default visit length + $this->trackerEventTsIterator += Config::getInstance()->Tracker['visit_standard_length'] + 1; + + $this->trackPageview($tracker, 'page-5'); + + $this->assertCounts([4, 3, 1], 1, 1); + } + + /** + * @dataProvider getUserIdOverwritesVisitorIdSetting + */ + public function testNewVisitTriggeredAtMidnight(bool $userIdOverwritesVisitorId) + { + $this->configureUserIdOverwritesVisitorId($userIdOverwritesVisitorId); + $tracker = $this->getTracker(); + + $this->trackPageview($tracker, 'page-1'); + $this->trackAction($tracker, 'action-1'); + + // move time by a day + $this->trackerEventTsIterator += 24 * 60 * 60; + + $this->trackPageview($tracker, 'page-3'); + $this->trackAction($tracker, 'action-3'); + + $this->assertCounts([2, 2], 1); + } + + /** + * @dataProvider getUserIdOverwritesVisitorIdSetting + */ + public function testNewVisitWhenCampaignChanges(bool $userIdOverwritesVisitorId) + { + $this->configureUserIdOverwritesVisitorId($userIdOverwritesVisitorId); + $tracker = $this->getTracker(); + + $tracker->setUrl('http://www.example.com/?utm_campaign=first'); + $this->trackPageview($tracker, 'page-1'); + + $tracker->setUrl('http://www.example.com/?utm_campaign=second'); + $this->trackPageview($tracker, 'page-2'); + + $this->assertCounts([1, 1], 1); + } + + private function assertCounts(array $visitsWithActionCount, int $visitorIds, int $userIds = null, int $configIds = null) + { + $this->assertVisitsWithActionCount($visitsWithActionCount); + $this->assertVisitorIdsCount($visitorIds); + if (!is_null($userIds)) { + $this->assertUserIdsCount($userIds); + } + if (!is_null($configIds)) { + $this->assertConfigIdsCount($configIds); + } + } + + private function assertVisitsWithActionCount($visitsWithActionCount) + { + $sql = 'SELECT COUNT(DISTINCT(a.idlink_va)) as actions FROM %1$s v LEFT JOIN %2$s a ON v.idvisit = a.idvisit GROUP BY v.idvisit'; + $visitCount = Db::fetchAll( + sprintf($sql, Common::prefixTable('log_visit'), Common::prefixTable('log_link_visit_action')) + ); + $this->assertEquals($visitsWithActionCount, array_column($visitCount, 'actions')); + } + + private function assertActionCount($expected) + { + $visitCount = Db::fetchOne("SELECT COUNT(*) FROM " . Common::prefixTable('log_link_visit_action')); + $this->assertEquals($expected, $visitCount); + } + + private function assertVisitorIdsCount($expected) + { + $visitorIdsCount = Db::fetchOne("SELECT COUNT(DISTINCT idvisitor) FROM " . Common::prefixTable('log_visit')); + $this->assertEquals($expected, $visitorIdsCount); + } + + private function assertUserIdsCount($expected) + { + $visitorIdsCount = Db::fetchOne("SELECT COUNT(DISTINCT user_id) FROM " . Common::prefixTable('log_visit')); + $this->assertEquals($expected, $visitorIdsCount); + } + + private function assertConfigIdsCount($expected) + { + $visitorIdsCount = Db::fetchOne("SELECT COUNT(DISTINCT config_id) FROM " . Common::prefixTable('log_visit')); + $this->assertEquals($expected, $visitorIdsCount); + } + + private function getVisitProperty($columnName, $idVisit) + { + return Db::fetchOne("SELECT $columnName FROM " . Common::prefixTable('log_visit') . " WHERE idvisit = ?", array($idVisit)); + } + + protected static function configureFixture($fixture) + { + parent::configureFixture($fixture); + $fixture->createSuperUser = true; + } +} diff --git a/tests/UI/specs/UserIdVisitorId_spec.js b/tests/UI/specs/UserIdVisitorId_spec.js new file mode 100644 index 00000000000..a4f22be60e3 --- /dev/null +++ b/tests/UI/specs/UserIdVisitorId_spec.js @@ -0,0 +1,619 @@ +/*! + * Matomo - free/libre analytics platform + * + * ViewDataTable screenshot tests. + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +describe('UserIdVisitorId', function () { + + const liveApiUrl = `${page.baseUrl}index.php?idSite=all&module=API&method=Live.getLastVisitsDetails&format=json`; + const siteUrl = config.piwikUrl + 'tests/resources/overlay-test-site-real/user-id-visitor-id.php'; + + const chromeUserAgent = 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36'; + const safariUserAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) AppleWebKit/537.75.14 (KHTML, like Gecko) Version/7.0.3 Safari/7046A194A'; + + let trackerEventOffset = 0; + let trackerDate = Date.parse('2024-01-01 10:00:00 UTC'); + + after(async function() { + await page.setUserAgent(page.originalUserAgent); + + delete testEnvironment.configOverride.Tracker; + testEnvironment.save(); + }); + + async function assertCounts(visitsWithActionCount, visitorIdCount) { + const visits = await fetchLastVisitDetails(); + + expect(visits.length).to.be.equal(visitsWithActionCount.length); + + const visitorIds = new Set(); + + for (let i = 0; i < visitsWithActionCount.length; i++) { + expect(visits[i].actionDetails.length).to.be.equal(visitsWithActionCount[i]); + + if (visits[i].visitorId) { + visitorIds.add(visits[i].visitorId); + } + } + + expect(visitorIds.size).to.be.equal(visitorIdCount); + } + + async function fetchLastVisitDetails() { + const date = new Date(trackerDate); + const [dateParam] = date.toISOString().split('T'); + + const visits = JSON.parse(await page.downloadUrl(`${liveApiUrl}&period=day&date=${dateParam}`)); + + // sort visits by idVisit ascending (API can return different order) + visits.sort(function (a, b) { return a.idVisit - b.idVisit; }); + + return visits; + } + + async function goToSite({ forceNewVisit, userId } = {}) { + const appendToUrl = []; + + if (forceNewVisit) { + appendToUrl.push('forceNewVisit=1'); + } + + if (userId) { + appendToUrl.push(`userId=${userId}`); + } + + await page.goto(siteUrl + (appendToUrl.length ? '?' + appendToUrl.join('&') : '')); + await page.waitForNetworkIdle(); + } + + async function trackAction(url) { + trackerEventOffset++; + + const visitDateTime = trackerEventOffset + Math.floor(trackerDate / 1000); + + await page.evaluate(function (url, cdt) { + window.trackAction(url, cdt); + }, url, visitDateTime); + + await page.waitForNetworkIdle(); + } + + async function trackPageView(url) { + trackerEventOffset++; + + const visitDateTime = trackerEventOffset + Math.floor(trackerDate / 1000); + + await page.evaluate(function (url, cdt) { + window.trackPageView(url, cdt); + }, url, visitDateTime); + + await page.waitForNetworkIdle(); + } + + [true, false].forEach(function (enableUserIdOverwritesVisitorId) { + describe(`enable_userid_overwrites_visitorid: ${enableUserIdOverwritesVisitorId ? 'enabled' : 'disabled'}`, function () { + before(function() { + testEnvironment.configOverride.Tracker = { + enable_userid_overwrites_visitorid: enableUserIdOverwritesVisitorId ? '1' : '0', + }; + testEnvironment.save(); + }); + + beforeEach(async function () { + // clear cookies to avoid tests affecting each other + // and generate new visits where we don't expect + await page.clearCookies(); + + // set our default custom user agent + await page.setUserAgent(chromeUserAgent); + + // move tracker to the next day + trackerDate += 86400 * 1000; + trackerEventOffset = 0; + }); + + it('tracks user that does not log in during visit', async function () { + await goToSite({ forceNewVisit: true }); + await trackPageView('page-1'); + await assertCounts([1], 1); + + await goToSite(); + await trackPageView('page-2'); + await assertCounts([2], 1); + await trackAction('action-1'); + await assertCounts([3], 1); + + await goToSite({ forceNewVisit: true }); + await trackPageView('page-3'); + await assertCounts([3, 1], 1); + await trackAction('action-2'); + await assertCounts([3, 2], 1); + }); + + it('tracks user that logs in during visit', async function () { + await goToSite({ forceNewVisit: true }); + await trackPageView('page-1'); + await assertCounts([1], 1); + + await goToSite(); + await trackPageView('page-2'); + await assertCounts([2], 1); + await trackAction('action-1'); + await assertCounts([3], 1); + + const visitorId1 = (await fetchLastVisitDetails())[0].visitorId; + + // log in user + await goToSite({ userId: this.test.title }); + await trackAction('log-in'); + await assertCounts([4], 1); + + const visitorId2 = (await fetchLastVisitDetails())[0].visitorId; + + if (enableUserIdOverwritesVisitorId) { + expect(visitorId1).to.not.be.equal(visitorId2); + } else { + expect(visitorId1).to.be.equal(visitorId2); + } + + await trackAction('action-2'); + await assertCounts([5], 1); + + await goToSite({ userId: this.test.title }); + await trackPageView('page-3'); + await assertCounts([6], 1); + }); + + it('tracks user that logs in during visit without actions', async function () { + await goToSite({ forceNewVisit: true }); + await trackPageView('page-1'); + await assertCounts([1], 1); + + await goToSite(); + await trackPageView('page-2'); + await assertCounts([2], 1); + + const visitorId1 = (await fetchLastVisitDetails())[0].visitorId; + + // log in user + await goToSite({ userId: this.test.title }); + await trackPageView('page-3'); + await assertCounts([3], 1); + + const visitorId2 = (await fetchLastVisitDetails())[0].visitorId; + + if (enableUserIdOverwritesVisitorId) { + expect(visitorId1).to.not.be.equal(visitorId2); + } else { + expect(visitorId1).to.be.equal(visitorId2); + } + + await goToSite({ userId: this.test.title }); + await trackPageView('page-4'); + await assertCounts([4], 1); + }); + + it('tracks user that logs in and out during visit', async function () { + await goToSite({ forceNewVisit: true }); + await trackPageView('page-1'); + await assertCounts([1], 1); + + await goToSite(); + await trackPageView('page-2'); + await assertCounts([2], 1); + await trackAction('action-1'); + await assertCounts([3], 1); + + const visitorId1 = (await fetchLastVisitDetails())[0].visitorId; + + // log in user + await goToSite({ userId: this.test.title }); + await trackAction('log-in'); + await assertCounts([4], 1); + + const visitorId2 = (await fetchLastVisitDetails())[0].visitorId; + + if (enableUserIdOverwritesVisitorId) { + expect(visitorId1).to.not.be.equal(visitorId2); + } else { + expect(visitorId1).to.be.equal(visitorId2); + } + + await trackAction('action-2'); + await assertCounts([5], 1); + + await goToSite({ userId: this.test.title }); + await trackPageView('page-3'); + await assertCounts([6], 1); + + // log out user + await goToSite(); + await trackAction('log-out'); + await assertCounts([7], 1); + + const visitorId3 = (await fetchLastVisitDetails())[0].visitorId; + + if (enableUserIdOverwritesVisitorId) { + expect(visitorId3).to.be.equal(visitorId1); + expect(visitorId3).to.not.be.equal(visitorId2); + } else { + expect(visitorId3).to.be.equal(visitorId1); + expect(visitorId2).to.be.equal(visitorId3); + } + + await trackAction('action-3'); + await assertCounts([8], 1); + }); + + it('tracks user that logs in and out during visit without actions', async function () { + await goToSite({ forceNewVisit: true }); + await trackPageView('page-1'); + await assertCounts([1], 1); + + await goToSite(); + await trackPageView('page-2'); + await assertCounts([2], 1); + + const visitorId1 = (await fetchLastVisitDetails())[0].visitorId; + + // log in user + await goToSite({ userId: this.test.title }); + await trackPageView('page-3'); + await assertCounts([3], 1); + + const visitorId2 = (await fetchLastVisitDetails())[0].visitorId; + + if (enableUserIdOverwritesVisitorId) { + expect(visitorId1).to.not.be.equal(visitorId2); + } else { + expect(visitorId1).to.be.equal(visitorId2); + } + + await goToSite({ userId: this.test.title }); + await trackPageView('page-4'); + await assertCounts([4], 1); + + // log out user + await goToSite(); + await trackPageView('page-5'); + await assertCounts([5], 1); + + const visitorId3 = (await fetchLastVisitDetails())[0].visitorId; + + if (enableUserIdOverwritesVisitorId) { + expect(visitorId3).to.be.equal(visitorId1); + expect(visitorId3).to.not.be.equal(visitorId2); + } else { + expect(visitorId1).to.be.equal(visitorId3); + expect(visitorId2).to.be.equal(visitorId3); + } + + await trackPageView('page-6'); + await assertCounts([6], 1); + }); + + it('tracks user that logs in on different devices', async function () { + await goToSite({ forceNewVisit: true }); + await trackPageView('page-1'); + await goToSite(); + await trackPageView('page-2'); + await trackAction('action-1'); + await goToSite({ userId: this.test.title }); + await trackAction('log-in'); + await assertCounts([4], 1); + + // switch device + await page.clearCookies(); + await page.setUserAgent(safariUserAgent); + + await goToSite({ forceNewVisit: true }); + await trackPageView('page-3'); + await goToSite(); + await trackPageView('page-4'); + await trackAction('action-2'); + await goToSite({ userId: this.test.title }); + await trackAction('log-in'); + + if (enableUserIdOverwritesVisitorId) { + await assertCounts([5, 3], 2); + } else { + await assertCounts([4, 4], 2); + } + + // expect different fingerprints for different devices + const visits = await fetchLastVisitDetails(); + + expect(visits[0].fingerprint).to.not.be.equal(visits[1].fingerprint); + }); + + it('tracks user that logs in on different devices without actions', async function () { + await goToSite({ forceNewVisit: true }); + await trackPageView('page-1'); + await goToSite(); + await trackPageView('page-2'); + await goToSite({ userId: this.test.title }); + await trackPageView('page-3'); + await assertCounts([3], 1); + + // switch device + await page.clearCookies(); + await page.setUserAgent(safariUserAgent); + + await goToSite({ forceNewVisit: true }); + await trackPageView('page-4'); + await goToSite(); + await trackPageView('page-5'); + await goToSite({ userId: this.test.title }); + await trackPageView('page-6'); + + if (enableUserIdOverwritesVisitorId) { + await assertCounts([4, 2], 2); + } else { + await assertCounts([3, 3], 2); + } + + // expect different fingerprints for different devices + const visits = await fetchLastVisitDetails(); + + expect(visits[0].fingerprint).to.not.be.equal(visits[1].fingerprint); + }); + + it('tracks user that logs in and out multiple times', async function () { + await goToSite({ forceNewVisit: true }); + await trackPageView('page-1'); + await goToSite(); + await trackPageView('page-2'); + await trackAction('action-1'); + await goToSite({ userId: this.test.title }); + await trackAction('log-in'); + await assertCounts([4], 1); + + const visitorId1 = (await fetchLastVisitDetails())[0].visitorId; + + await goToSite({ userId: this.test.title }); + await trackAction('action-2'); + await trackPageView('page-3'); + await assertCounts([6], 1); + + await goToSite(); + await trackAction('log-out'); + await assertCounts([7], 1); + + const visitorId2 = (await fetchLastVisitDetails())[0].visitorId; + + // force new visit and log in + await goToSite({ forceNewVisit: true }); + await trackAction('action-3'); + await trackPageView('page-4'); + await goToSite({ userId: this.test.title }); + await trackAction('log-in'); + + if (enableUserIdOverwritesVisitorId) { + await assertCounts([7, 3], 2); + } else { + await assertCounts([7, 3], 1); + } + + const visitorId3 = (await fetchLastVisitDetails())[1].visitorId; + + await goToSite({ userId: this.test.title }); + await trackAction('action-4'); + await trackPageView('page-5'); + + if (enableUserIdOverwritesVisitorId) { + await assertCounts([7, 5], 2); + } else { + await assertCounts([7, 5], 1); + } + + // await page.clearCookies(); + await goToSite(); + await trackAction('log-out'); + + if (enableUserIdOverwritesVisitorId) { + await assertCounts([8, 5], 2); + } else { + await assertCounts([7, 6], 1); + } + + const visitorId4 = (await fetchLastVisitDetails())[1].visitorId; + + if (enableUserIdOverwritesVisitorId) { + expect(visitorId1).to.not.be.equal(visitorId2); + expect(visitorId1).to.be.equal(visitorId3); + expect(visitorId1).to.be.equal(visitorId4); + } else { + expect(visitorId1).to.be.equal(visitorId2); + expect(visitorId1).to.be.equal(visitorId3); + expect(visitorId3).to.be.equal(visitorId4); + } + }); + + it('tracks user that logs in and out multiple times without actions', async function () { + await goToSite({ forceNewVisit: true }); + await trackPageView('page-1'); + await goToSite(); + await trackPageView('page-2'); + await goToSite({ userId: this.test.title }); + await trackPageView('log-in'); + await assertCounts([3], 1); + + const visitorId1 = (await fetchLastVisitDetails())[0].visitorId; + + await goToSite({ userId: this.test.title }); + await trackPageView('page-4'); + await assertCounts([4], 1); + + await goToSite(); + await trackPageView('log-out'); + await assertCounts([5], 1); + + const visitorId2 = (await fetchLastVisitDetails())[0].visitorId; + + // force new visit and log in + await page.clearCookies(); + await goToSite({ forceNewVisit: true }); + await trackPageView('page-6'); + await goToSite({ userId: this.test.title }); + await trackPageView('log-in'); + + await assertCounts([5, 2], 2); + + const visitorId3 = (await fetchLastVisitDetails())[1].visitorId; + + await goToSite({ userId: this.test.title }); + await trackPageView('page-7'); + + await assertCounts([5, 3], 2); + + await goToSite(); + await trackPageView('log-out'); + + await assertCounts([5, 4], 2); + + const visitorId4 = (await fetchLastVisitDetails())[1].visitorId; + + if (enableUserIdOverwritesVisitorId) { + expect(visitorId1).to.not.be.equal(visitorId2); + expect(visitorId1).to.be.equal(visitorId3); + expect(visitorId2).to.be.not.equal(visitorId4); + } else { + expect(visitorId1).to.be.equal(visitorId2); + expect(visitorId1).to.be.not.equal(visitorId3); + expect(visitorId3).to.be.equal(visitorId4); + } + }); + + it('tracks user with new visit and login/logout combination', async function () { + await goToSite({ forceNewVisit: true }); + await trackPageView('page-1'); + await goToSite(); + await trackPageView('page-2'); + await assertCounts([2], 1); + + await goToSite({ forceNewVisit: true }); + await trackPageView('page-3'); + await assertCounts([2, 1], 1); + + const visitorId1 = (await fetchLastVisitDetails())[1].visitorId; + + await goToSite({ userId: this.test.title }); + await trackPageView('page-4'); + + const visitorId2 = (await fetchLastVisitDetails())[1].visitorId; + + if (enableUserIdOverwritesVisitorId) { + expect(visitorId1).to.be.not.equal(visitorId2); + await assertCounts([2, 2], 2); + } else { + expect(visitorId1).to.be.equal(visitorId2); + await assertCounts([2, 2], 1); + } + + await goToSite(); + await trackPageView('page-5'); + + if (enableUserIdOverwritesVisitorId) { + await assertCounts([3, 2], 2); + } else { + await assertCounts([2, 3], 1); + } + }); + + it('tracks new visit due to inactivity', async function () { + const initialTrackerDate = trackerDate; + + let visits; + + await goToSite({ forceNewVisit: true }); + await trackPageView('page-1'); + await goToSite(); + await trackPageView('page-2'); + await trackAction('action-1'); + await goToSite({ userId: this.test.title }); + await trackAction('log-in'); + await assertCounts([4], 1); + + visits = await fetchLastVisitDetails(); + + const visitorId1 = visits[0].visitorId; + const userId1 = visits[0].userId; + + // move tracker date beyond default visit length (6 hours should be enough) + trackerDate += (6 * 3600) * 1000; + + await goToSite({ userId: this.test.title }); + await trackPageView('page-3'); + await goToSite({ userId: this.test.title }); + await trackPageView('page-4'); + await trackAction('action-5'); + await assertCounts([4, 3], 1); + + visits = await fetchLastVisitDetails(); + + const visitorId2 = visits[0].visitorId; + const userId2 = visits[0].userId; + + expect(visitorId1).to.be.equal(visitorId2); + expect(userId1).to.be.equal(userId2); + + // move tracker date beyond default visit length (6 hours should be enough) + trackerDate += (6 * 3600) * 1000; + + await goToSite({ userId: this.test.title }); + await trackPageView('page-5'); + await assertCounts([4, 3, 1], 1); + + // reset tracker date back to initial value for next test + trackerDate = initialTrackerDate; + }); + + it('tracks new visit at midnight', async function () { + let visits; + + await goToSite({ forceNewVisit: true }); + await trackPageView('page-1'); + await trackAction('action-1'); + await assertCounts([2], 1); + + visits = await fetchLastVisitDetails(); + + const visitId1 = visits[0].idVisit; + const visitorId1 = visits[0].visitorId; + + // move tracker date to next day + trackerDate += 86400 * 1000; + + await goToSite(); + await trackPageView('page-2'); + await trackAction('action-2'); + + // counting is done for actions of a single day only + // previous day visit has to be compared separately + await assertCounts([2], 1); + + visits = await fetchLastVisitDetails(); + + const visitId2 = visits[0].idVisit; + const visitorId2 = visits[0].visitorId; + + expect(visitId1).to.be.not.equal(visitId2); + expect(visitorId1).to.be.equal(visitorId2); + }); + + it('tracks new visit when campaign changes', async function () { + await goToSite({ forceNewVisit: true }); + await trackPageView('page-1?utm_campaign=first'); + + await goToSite(); + await trackPageView('page-1?utm_campaign=second'); + + await assertCounts([1, 1], 1); + }); + }); + }); +}); diff --git a/tests/resources/overlay-test-site/user-id-visitor-id.php b/tests/resources/overlay-test-site/user-id-visitor-id.php new file mode 100644 index 00000000000..3788eb99813 --- /dev/null +++ b/tests/resources/overlay-test-site/user-id-visitor-id.php @@ -0,0 +1,71 @@ + + + + User ID / Visitor ID Test + + + + +

User ID / Visitor ID

+ + + +