diff --git a/modules/.submodules.json b/modules/.submodules.json index 5aa83c64376..a1b2971bfea 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -18,6 +18,7 @@ "fabrickIdSystem", "freepassIdSystem", "ftrackIdSystem", + "gemiusIdSystem", "gravitoIdSystem", "growthCodeIdSystem", "hadronIdSystem", diff --git a/modules/gemiusIdSystem.md b/modules/gemiusIdSystem.md new file mode 100644 index 00000000000..e285a673db6 --- /dev/null +++ b/modules/gemiusIdSystem.md @@ -0,0 +1,31 @@ +## Gemius User ID Submodule + +This module supports [Gemius](https://gemius.com/) customers in using Real Users ID (RUID) functionality. + +## Building Prebid.js with Gemius User ID Submodule + +To build Prebid.js with the `gemiusIdSystem` module included: + +``` +gulp build --modules=userId,gemiusIdSystem +``` + +### Prebid Configuration + +You can configure this submodule in your `userSync.userIds[]` configuration: + +```javascript +pbjs.setConfig({ + userSync: { + userIds: [{ + name: 'gemiusId', + storage: { + name: 'pbjs_gemiusId', + type: 'cookie', + expires: 30, + refreshInSeconds: 3600 + } + }] + } +}); +``` diff --git a/modules/gemiusIdSystem.ts b/modules/gemiusIdSystem.ts new file mode 100644 index 00000000000..e5e6d335857 --- /dev/null +++ b/modules/gemiusIdSystem.ts @@ -0,0 +1,122 @@ +import { logInfo, logError, isStr, getWindowTop, canAccessWindowTop, getWindowSelf } from '../src/utils.js'; +import { submodule } from '../src/hook.js'; +import { AllConsentData } from "../src/consentHandler.ts"; + +import type { IdProviderSpec } from './userId/spec.ts'; + +const MODULE_NAME = 'gemiusId' as const; +const GVLID = 328; +const REQUIRED_PURPOSES = [1, 2, 3, 4, 7, 8, 9, 10]; +const LOG_PREFIX = 'Gemius User ID: '; + +const WAIT_FOR_PRIMARY_SCRIPT_MAX_TRIES = 8; +const WAIT_FOR_PRIMARY_SCRIPT_INITIAL_WAIT_MS = 50; +const GEMIUS_CMD_TIMEOUT = 8000; + +type SerializableId = string | Record; +type PrimaryScriptWindow = Window & { + gemius_cmd: (action: string, callback: (ruid: string, desc: { status: string }) => void) => void; +}; + +declare module './userId/spec' { + interface UserId { + gemiusId: string; + } + interface ProvidersToId { + gemiusId: 'gemiusId'; + } +} + +function getTopAccessibleWindow(): Window { + if (canAccessWindowTop()) { + return getWindowTop(); + } + + return getWindowSelf(); +} + +function retrieveId(primaryScriptWindow: PrimaryScriptWindow, callback: (id: SerializableId) => void): void { + let resultResolved = false; + let timeoutId: number | null = null; + const setResult = function (id?: SerializableId): void { + if (resultResolved) { + return; + } + + resultResolved = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + callback(id); + } + + timeoutId = setTimeout(() => { + logError(LOG_PREFIX + 'failed to get id, timeout'); + timeoutId = null; + setResult(); + }, GEMIUS_CMD_TIMEOUT); + + try { + primaryScriptWindow.gemius_cmd('get_ruid', function (ruid, desc) { + if (desc.status === 'ok') { + setResult({id: ruid}); + } else if (desc.status === 'no-consent') { + logInfo(LOG_PREFIX + 'failed to get id, no consent'); + setResult({id: null}); + } else { + logError(LOG_PREFIX + 'failed to get id, response: ' + desc.status); + setResult(); + } + }); + } catch (e) { + logError(LOG_PREFIX + 'failed to get id, error: ' + e); + setResult(); + } +} + +export const gemiusIdSubmodule: IdProviderSpec = { + name: MODULE_NAME, + gvlid: GVLID, + decode(value) { + if (isStr(value?.['id'])) { + return {[MODULE_NAME]: value['id']}; + } + return undefined; + }, + getId(_, {gdpr: consentData}: Partial = {}) { + if (consentData && typeof consentData.gdprApplies === 'boolean' && consentData.gdprApplies) { + if (REQUIRED_PURPOSES.some(purposeId => !(consentData.vendorData?.purpose as any)?.consents?.[purposeId])) { + logInfo(LOG_PREFIX + 'getId, no consent'); + return {id: {id: null}}; + } + } + + logInfo(LOG_PREFIX + 'getId'); + return { + callback: function (callback) { + const win = getTopAccessibleWindow(); + + (function waitForPrimaryScript(tryCount = 1, nextWaitTime = WAIT_FOR_PRIMARY_SCRIPT_INITIAL_WAIT_MS) { + if (typeof win['gemius_cmd'] !== 'undefined') { + retrieveId(win as PrimaryScriptWindow, callback); + } + + if (tryCount < WAIT_FOR_PRIMARY_SCRIPT_MAX_TRIES) { + setTimeout(() => waitForPrimaryScript(tryCount + 1, nextWaitTime * 2), nextWaitTime); + } else { + callback(undefined); + } + })(); + } + }; + }, + eids: { + [MODULE_NAME]: { + source: 'gemius.com', + atype: '1', + }, + } +}; + +submodule('userId', gemiusIdSubmodule); diff --git a/modules/userId/eids.md b/modules/userId/eids.md index 53567032175..f6f62229f53 100644 --- a/modules/userId/eids.md +++ b/modules/userId/eids.md @@ -335,6 +335,13 @@ userIdAsEids = [ id: 'some-random-id-value', atype: 1 }] + }, + { + source: 'gemius.com'', + uids: [{ + id: 'some-random-id-value', + atype: 1 + }] } ] ``` diff --git a/modules/userId/userId.md b/modules/userId/userId.md index 9aea3e8d533..9cc2ce5bf5c 100644 --- a/modules/userId/userId.md +++ b/modules/userId/userId.md @@ -137,7 +137,9 @@ pbjs.setConfig({ name: '__adm__admixer', expires: 30 } - },{ + }, { + name: "gemiusId" + }, { name: "kpuid", params:{ accountid: 124 // example of account id diff --git a/test/spec/modules/gemiusIdSystem_spec.js b/test/spec/modules/gemiusIdSystem_spec.js new file mode 100644 index 00000000000..76c4549a321 --- /dev/null +++ b/test/spec/modules/gemiusIdSystem_spec.js @@ -0,0 +1,151 @@ +import { gemiusIdSubmodule } from 'modules/gemiusIdSystem.ts'; +import * as utils from 'src/utils.js'; + +describe('GemiusId module', function () { + let getWindowTopStub; + let mockWindow; + let clock; + + beforeEach(function () { + mockWindow = { + gemius_cmd: sinon.stub() + }; + getWindowTopStub = sinon.stub(utils, 'getWindowTop').returns(mockWindow); + }); + + afterEach(function () { + getWindowTopStub.restore(); + if (clock) { + clock.restore(); + clock = undefined; + } + }); + + describe('gemiusIdSubmodule', function () { + it('should have the correct name', function () { + expect(gemiusIdSubmodule.name).to.equal('gemiusId'); + }); + + it('should have correct eids configuration', function () { + expect(gemiusIdSubmodule.eids.gemiusId).to.deep.include({ + source: 'gemius.com', + atype: '1' + }); + }); + + it('should have correct gvlid', function () { + expect(gemiusIdSubmodule.gvlid).to.be.a('number'); + }); + }); + + describe('getId', function () { + const gdprConsentData = { + gdprApplies: true, + apiVersion: 2, + vendorData: { + purpose: { + consents: { + 1: true, + 2: false, + 3: true, 4: true, 5: true, 6: true, 7: true, 8: true, 9: true, 10: true, 11: true + } + } + } + }; + + it('should return undefined if gemius_cmd is not available', function (done) { + clock = sinon.useFakeTimers(); + getWindowTopStub.returns({}); + + gemiusIdSubmodule.getId().callback((resultId) => { + expect(resultId).to.be.undefined; + done(); + }); + + clock.tick(6400); + }); + + it('should return null id if no consent', function () { + const result = gemiusIdSubmodule.getId({}, { + gdpr: gdprConsentData + }); + expect(result).to.deep.equal({id: {id: null}}); + }); + + it('should return callback on consent', function () { + const result = gemiusIdSubmodule.getId({}, { + gdpr: utils.deepClone(gdprConsentData).vendorData.purpose.consents["2"] = true + }); + expect(result).to.have.property('callback'); + expect(result.callback).to.be.a('function'); + }); + + it('should return callback when gemius_cmd is available', function () { + const result = gemiusIdSubmodule.getId(); + expect(result).to.have.property('callback'); + expect(result.callback).to.be.a('function'); + }); + + it('should call gemius_cmd with correct parameters', function (done) { + mockWindow.gemius_cmd.callsFake((command, callback) => { + expect(command).to.equal('get_ruid'); + expect(callback).to.be.a('function'); + + const testRuid = 'test-ruid-123'; + const statusOk = {status: 'ok'}; + callback(testRuid, statusOk); + }); + + gemiusIdSubmodule.getId().callback((resultId) => { + expect(resultId).to.deep.equal({id: 'test-ruid-123'}); + expect(mockWindow.gemius_cmd.calledOnce).to.be.true; + done(); + }); + }); + + it('should handle gemius_cmd throwing an error', function (done) { + mockWindow.gemius_cmd.callsFake(() => { + throw new Error(); + }); + + const result = gemiusIdSubmodule.getId(); + result.callback((resultId) => { + expect(resultId).to.be.undefined; + done(); + }); + }); + + it('should handle gemius_cmd not calling callback', function (done) { + const clock = sinon.useFakeTimers(); + + mockWindow.gemius_cmd.callsFake((command, callback) => { + // Don't call callback to simulate timeout/no response + }); + + gemiusIdSubmodule.getId().callback((resultId) => { + expect(resultId).to.be.undefined; + clock.restore(); + done(); + }); + + clock.tick(8100); + }); + }); + + describe('decode', function () { + it('should return object with gemiusId when value exists', function () { + const result = gemiusIdSubmodule.decode({id: 'test-gemius-id'}); + expect(result).to.deep.equal({ + gemiusId: 'test-gemius-id' + }); + }); + + it('should return undefined when value is falsy', function () { + expect(gemiusIdSubmodule.decode('')).to.be.undefined; + expect(gemiusIdSubmodule.decode(null)).to.be.undefined; + expect(gemiusIdSubmodule.decode(undefined)).to.be.undefined; + expect(gemiusIdSubmodule.decode(0)).to.be.undefined; + expect(gemiusIdSubmodule.decode(false)).to.be.undefined; + }); + }); +});