From f3f89b3dc19fd375808bc56c7e3759a298667bad Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Tue, 27 Sep 2022 21:47:15 +0200 Subject: [PATCH] [CST-6753] track Google Analytics statistic only if user accepts cookie consents --- .../cookies/browser-klaro.service.spec.ts | 62 ++++++++++++--- .../shared/cookies/browser-klaro.service.ts | 19 ++++- src/app/shared/cookies/klaro-configuration.ts | 4 +- src/app/shared/cookies/klaro.service.ts | 9 ++- .../google-analytics.service.spec.ts | 78 +++++++++++++++++-- .../statistics/google-analytics.service.ts | 58 +++++++++----- 6 files changed, 190 insertions(+), 40 deletions(-) diff --git a/src/app/shared/cookies/browser-klaro.service.spec.ts b/src/app/shared/cookies/browser-klaro.service.spec.ts index 5806148d94..c7b08a45c9 100644 --- a/src/app/shared/cookies/browser-klaro.service.spec.ts +++ b/src/app/shared/cookies/browser-klaro.service.spec.ts @@ -10,10 +10,12 @@ import { AuthService } from '../../core/auth/auth.service'; import { CookieService } from '../../core/services/cookie.service'; import { getTestScheduler } from 'jasmine-marbles'; import { MetadataValue } from '../../core/shared/metadata.models'; -import {clone, cloneDeep} from 'lodash'; +import { clone, cloneDeep } from 'lodash'; import { ConfigurationDataService } from '../../core/data/configuration-data.service'; -import {createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$} from '../remote-data.utils'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; +import { ANONYMOUS_STORAGE_NAME_KLARO } from './klaro-configuration'; +import { TestScheduler } from 'rxjs/testing'; describe('BrowserKlaroService', () => { const trackingIdProp = 'google.analytics.key'; @@ -29,7 +31,7 @@ describe('BrowserKlaroService', () => { let configurationDataService: ConfigurationDataService; const createConfigSuccessSpy = (...values: string[]) => jasmine.createSpyObj('configurationDataService', { findByPropertyName: createSuccessfulRemoteDataObject$({ - ... new ConfigurationProperty(), + ...new ConfigurationProperty(), name: trackingIdProp, values: values, }), @@ -42,7 +44,9 @@ describe('BrowserKlaroService', () => { let findByPropertyName; beforeEach(() => { - user = new EPerson(); + user = Object.assign(new EPerson(), { + uuid: 'test-user' + }); translateService = getMockTranslateService(); ePersonService = jasmine.createSpyObj('ePersonService', { @@ -104,7 +108,7 @@ describe('BrowserKlaroService', () => { services: [{ name: appName, purposes: [purpose] - },{ + }, { name: googleAnalytics, purposes: [purpose] }], @@ -219,6 +223,40 @@ describe('BrowserKlaroService', () => { }); }); + describe('getSavedPreferences', () => { + let scheduler: TestScheduler; + beforeEach(() => { + scheduler = getTestScheduler(); + }); + + describe('when no user is autheticated', () => { + beforeEach(() => { + spyOn(service as any, 'getUser$').and.returnValue(observableOf(undefined)); + }); + + it('should return the cookie consents object', () => { + scheduler.schedule(() => service.getSavedPreferences().subscribe()); + scheduler.flush(); + + expect(cookieService.get).toHaveBeenCalledWith(ANONYMOUS_STORAGE_NAME_KLARO); + }); + }); + + describe('when user is autheticated', () => { + beforeEach(() => { + spyOn(service as any, 'getUser$').and.returnValue(observableOf(user)); + }); + + it('should return the cookie consents object', () => { + scheduler.schedule(() => service.getSavedPreferences().subscribe()); + scheduler.flush(); + + expect(cookieService.get).toHaveBeenCalledWith('klaro-' + user.uuid); + }); + }); + }); + + describe('setSettingsForUser when there are changes', () => { const cookieConsent = { test: 'testt' }; const cookieConsentString = '{test: \'testt\'}'; @@ -271,40 +309,40 @@ describe('BrowserKlaroService', () => { }); it('should not filter googleAnalytics when servicesToHide are empty', () => { const filteredConfig = (service as any).filterConfigServices([]); - expect(filteredConfig).toContain(jasmine.objectContaining({name: googleAnalytics})); + expect(filteredConfig).toContain(jasmine.objectContaining({ name: googleAnalytics })); }); it('should filter services using names passed as servicesToHide', () => { const filteredConfig = (service as any).filterConfigServices([googleAnalytics]); - expect(filteredConfig).not.toContain(jasmine.objectContaining({name: googleAnalytics})); + expect(filteredConfig).not.toContain(jasmine.objectContaining({ name: googleAnalytics })); }); it('should have been initialized with googleAnalytics', () => { service.initialize(); - expect(service.klaroConfig.services).toContain(jasmine.objectContaining({name: googleAnalytics})); + expect(service.klaroConfig.services).toContain(jasmine.objectContaining({ name: googleAnalytics })); }); it('should filter googleAnalytics when empty configuration is retrieved', () => { configurationDataService.findByPropertyName = jasmine.createSpy().withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue( createSuccessfulRemoteDataObject$({ - ... new ConfigurationProperty(), + ...new ConfigurationProperty(), name: googleAnalytics, values: [], })); service.initialize(); - expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({name: googleAnalytics})); + expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({ name: googleAnalytics })); }); it('should filter googleAnalytics when an error occurs', () => { configurationDataService.findByPropertyName = jasmine.createSpy().withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue( createFailedRemoteDataObject$('Erro while loading GA') ); service.initialize(); - expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({name: googleAnalytics})); + expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({ name: googleAnalytics })); }); it('should filter googleAnalytics when an invalid payload is retrieved', () => { configurationDataService.findByPropertyName = jasmine.createSpy().withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue( createSuccessfulRemoteDataObject$(null) ); service.initialize(); - expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({name: googleAnalytics})); + expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({ name: googleAnalytics })); }); }); }); diff --git a/src/app/shared/cookies/browser-klaro.service.ts b/src/app/shared/cookies/browser-klaro.service.ts index 6929fb0695..0648afd17a 100644 --- a/src/app/shared/cookies/browser-klaro.service.ts +++ b/src/app/shared/cookies/browser-klaro.service.ts @@ -13,7 +13,7 @@ import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { cloneDeep, debounce } from 'lodash'; import { ANONYMOUS_STORAGE_NAME_KLARO, klaroConfiguration } from './klaro-configuration'; import { Operation } from 'fast-json-patch'; -import { getFirstCompletedRemoteData} from '../../core/shared/operators'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; import { ConfigurationDataService } from '../../core/data/configuration-data.service'; /** @@ -121,6 +121,23 @@ export class BrowserKlaroService extends KlaroService { }); } + /** + * Return saved preferences stored in the klaro cookie + */ + getSavedPreferences(): Observable { + return this.getUser$().pipe( + map((user: EPerson) => { + let storageName; + if (isEmpty(user)) { + storageName = ANONYMOUS_STORAGE_NAME_KLARO; + } else { + storageName = this.getStorageName(user.uuid); + } + return this.cookieService.get(storageName); + }) + ); + } + /** * Initialize configuration for the logged in user * @param user The authenticated user diff --git a/src/app/shared/cookies/klaro-configuration.ts b/src/app/shared/cookies/klaro-configuration.ts index 659583ad87..fb7c660322 100644 --- a/src/app/shared/cookies/klaro-configuration.ts +++ b/src/app/shared/cookies/klaro-configuration.ts @@ -12,6 +12,8 @@ export const HAS_AGREED_END_USER = 'dsHasAgreedEndUser'; */ export const ANONYMOUS_STORAGE_NAME_KLARO = 'klaro-anonymous'; +export const GOOGLE_ANALYTICS_KLARO_KEY = 'google-analytics'; + /** * Klaro configuration * For more information see https://kiprotect.com/docs/klaro/annotated-config @@ -113,7 +115,7 @@ export const klaroConfiguration: any = { ] }, { - name: 'google-analytics', + name: GOOGLE_ANALYTICS_KLARO_KEY, purposes: ['statistical'], required: false, cookies: [ diff --git a/src/app/shared/cookies/klaro.service.ts b/src/app/shared/cookies/klaro.service.ts index 64dee85b65..d54fed8b30 100644 --- a/src/app/shared/cookies/klaro.service.ts +++ b/src/app/shared/cookies/klaro.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + /** * Abstract class representing a service for handling Klaro consent preferences and UI */ @@ -11,7 +13,12 @@ export abstract class KlaroService { abstract initialize(); /** - * Shows a the dialog with the current consent preferences + * Shows a dialog with the current consent preferences */ abstract showSettings(); + + /** + * Return saved preferences stored in the klaro cookie + */ + abstract getSavedPreferences(): Observable; } diff --git a/src/app/statistics/google-analytics.service.spec.ts b/src/app/statistics/google-analytics.service.spec.ts index 24c5345260..5953f3d8cc 100644 --- a/src/app/statistics/google-analytics.service.spec.ts +++ b/src/app/statistics/google-analytics.service.spec.ts @@ -1,8 +1,12 @@ -import { GoogleAnalyticsService } from './google-analytics.service'; import { Angulartics2GoogleTagManager } from 'angulartics2'; +import { of } from 'rxjs'; + +import { GoogleAnalyticsService } from './google-analytics.service'; import { ConfigurationDataService } from '../core/data/configuration-data.service'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { ConfigurationProperty } from '../core/shared/configuration-property.model'; +import { KlaroService } from '../shared/cookies/klaro.service'; +import { GOOGLE_ANALYTICS_KLARO_KEY } from '../shared/cookies/klaro-configuration'; describe('GoogleAnalyticsService', () => { const trackingIdProp = 'google.analytics.key'; @@ -12,6 +16,7 @@ describe('GoogleAnalyticsService', () => { let service: GoogleAnalyticsService; let angularticsSpy: Angulartics2GoogleTagManager; let configSpy: ConfigurationDataService; + let klaroServiceSpy: jasmine.SpyObj; let scriptElementMock: any; let srcSpy: any; let innerHTMLSpy: any; @@ -31,6 +36,10 @@ describe('GoogleAnalyticsService', () => { 'startTracking', ]); + klaroServiceSpy = jasmine.createSpyObj('KlaroService', { + 'getSavedPreferences': jasmine.createSpy('getSavedPreferences') + }); + configSpy = createConfigSuccessSpy(trackingIdTestValue); scriptElementMock = { @@ -53,7 +62,11 @@ describe('GoogleAnalyticsService', () => { body: bodyElementSpy, }); - service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy); + klaroServiceSpy.getSavedPreferences.and.returnValue(of({ + GOOGLE_ANALYTICS_KLARO_KEY: true + })); + + service = new GoogleAnalyticsService(angularticsSpy, klaroServiceSpy, configSpy, documentSpy ); }); it('should be created', () => { @@ -73,7 +86,11 @@ describe('GoogleAnalyticsService', () => { findByPropertyName: createFailedRemoteDataObject$(), }); - service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy); + klaroServiceSpy.getSavedPreferences.and.returnValue(of({ + GOOGLE_ANALYTICS_KLARO_KEY: true + })); + + service = new GoogleAnalyticsService(angularticsSpy, klaroServiceSpy, configSpy, documentSpy); }); it('should NOT add a script to the body', () => { @@ -91,7 +108,10 @@ describe('GoogleAnalyticsService', () => { describe('when the tracking id is empty', () => { beforeEach(() => { configSpy = createConfigSuccessSpy(); - service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy); + klaroServiceSpy.getSavedPreferences.and.returnValue(of({ + [GOOGLE_ANALYTICS_KLARO_KEY]: true + })); + service = new GoogleAnalyticsService(angularticsSpy, klaroServiceSpy, configSpy, documentSpy); }); it('should NOT add a script to the body', () => { @@ -105,7 +125,55 @@ describe('GoogleAnalyticsService', () => { }); }); - describe('when the tracking id is non-empty', () => { + describe('when google-analytics cookie preferences are not existing', () => { + beforeEach(() => { + configSpy = createConfigSuccessSpy(trackingIdTestValue); + klaroServiceSpy.getSavedPreferences.and.returnValue(of({})); + service = new GoogleAnalyticsService(angularticsSpy, klaroServiceSpy, configSpy, documentSpy); + }); + + it('should NOT add a script to the body', () => { + service.addTrackingIdToPage(); + expect(bodyElementSpy.appendChild).toHaveBeenCalledTimes(0); + }); + + it('should NOT start tracking', () => { + service.addTrackingIdToPage(); + expect(angularticsSpy.startTracking).toHaveBeenCalledTimes(0); + }); + }); + + + describe('when google-analytics cookie preferences are set to false', () => { + beforeEach(() => { + configSpy = createConfigSuccessSpy(trackingIdTestValue); + klaroServiceSpy.getSavedPreferences.and.returnValue(of({ + [GOOGLE_ANALYTICS_KLARO_KEY]: false + })); + service = new GoogleAnalyticsService(angularticsSpy, klaroServiceSpy, configSpy, documentSpy); + }); + + it('should NOT add a script to the body', () => { + service.addTrackingIdToPage(); + expect(bodyElementSpy.appendChild).toHaveBeenCalledTimes(0); + }); + + it('should NOT start tracking', () => { + service.addTrackingIdToPage(); + expect(angularticsSpy.startTracking).toHaveBeenCalledTimes(0); + }); + }); + + describe('when both google-analytics cookie and the tracking id are non-empty', () => { + + beforeEach(() => { + configSpy = createConfigSuccessSpy(trackingIdTestValue); + klaroServiceSpy.getSavedPreferences.and.returnValue(of({ + [GOOGLE_ANALYTICS_KLARO_KEY]: true + })); + service = new GoogleAnalyticsService(angularticsSpy, klaroServiceSpy, configSpy, documentSpy); + }); + it('should create a script tag whose innerHTML contains the tracking id', () => { service.addTrackingIdToPage(); expect(documentSpy.createElement).toHaveBeenCalledTimes(2); diff --git a/src/app/statistics/google-analytics.service.ts b/src/app/statistics/google-analytics.service.ts index 6bdff53d52..d8a9c65959 100644 --- a/src/app/statistics/google-analytics.service.ts +++ b/src/app/statistics/google-analytics.service.ts @@ -1,9 +1,14 @@ +import { DOCUMENT } from '@angular/common'; import { Inject, Injectable } from '@angular/core'; + import { Angulartics2GoogleTagManager } from 'angulartics2'; +import { combineLatest } from 'rxjs'; + import { ConfigurationDataService } from '../core/data/configuration-data.service'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; import { isEmpty } from '../shared/empty.util'; -import { DOCUMENT } from '@angular/common'; +import { KlaroService } from '../shared/cookies/klaro.service'; +import { GOOGLE_ANALYTICS_KLARO_KEY } from '../shared/cookies/klaro-configuration'; /** * Set up Google Analytics on the client side. @@ -15,9 +20,11 @@ export class GoogleAnalyticsService { constructor( // private angulartics: Angulartics2GoogleAnalytics, private angulartics: Angulartics2GoogleTagManager, + private klaroService: KlaroService, private configService: ConfigurationDataService, @Inject(DOCUMENT) private document: any, - ) { } + ) { + } /** * Call this method once when Angular initializes on the client side. @@ -26,29 +33,40 @@ export class GoogleAnalyticsService { * page and starts tracking. */ addTrackingIdToPage(): void { - this.configService.findByPropertyName('google.analytics.key').pipe( + const googleKey$ = this.configService.findByPropertyName('google.analytics.key').pipe( getFirstCompletedRemoteData(), - ).subscribe((remoteData) => { - // make sure we got a success response from the backend - if (!remoteData.hasSucceeded) { return; } + ); + combineLatest([this.klaroService.getSavedPreferences(), googleKey$]) + .subscribe(([preferences, remoteData]) => { + // make sure user has accepted Google Analytics consents + if (isEmpty(preferences) || isEmpty(preferences[GOOGLE_ANALYTICS_KLARO_KEY]) || !preferences[GOOGLE_ANALYTICS_KLARO_KEY]) { + return; + } - const trackingId = remoteData.payload.values[0]; + // make sure we got a success response from the backend + if (!remoteData.hasSucceeded) { + return; + } - // make sure we received a tracking id - if (isEmpty(trackingId)) { return; } + const trackingId = remoteData.payload.values[0]; - // add GTag snippet to page - const keyScript = this.document.createElement('script'); - keyScript.src = `https://www.googletagmanager.com/gtag/js?id=${trackingId}`; - this.document.body.appendChild(keyScript); + // make sure we received a tracking id + if (isEmpty(trackingId)) { + return; + } - const libScript = this.document.createElement('script'); - libScript.innerHTML = `window.dataLayer = window.dataLayer || [];function gtag(){window.dataLayer.push(arguments);} - gtag('js', new Date());gtag('config', '${trackingId}');`; - this.document.body.appendChild(libScript); + // add GTag snippet to page + const keyScript = this.document.createElement('script'); + keyScript.src = `https://www.googletagmanager.com/gtag/js?id=${trackingId}`; + this.document.body.appendChild(keyScript); - // start tracking - this.angulartics.startTracking(); - }); + const libScript = this.document.createElement('script'); + libScript.innerHTML = `window.dataLayer = window.dataLayer || [];function gtag(){window.dataLayer.push(arguments);} + gtag('js', new Date());gtag('config', '${trackingId}');`; + this.document.body.appendChild(libScript); + + // start tracking + this.angulartics.startTracking(); + }); } }