___TERMS_OF_SERVICE___ By creating or modifying this file you agree to Google Tag Manager's Community Template Gallery Developer Terms of Service available at https://developers.google.com/tag-manager/gallery-tos (or such other URL as Google may provide), as modified from time to time. ___INFO___ { "type": "TAG", "id": "cvt_temp_public_id", "version": 1, "securityGroups": [], "displayName": "FlexyConsent CMP", "categories": [ "TAG_MANAGEMENT", "UTILITY" ], "brand": { "id": "github.com_flexyconsent", "displayName": "FlexyConsent", "thumbnail": "data:image/png;base64,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" }, "description": "Ask visitors for consent and make your Google tags follow their answer, without editing your site's code. Shows the FlexyConsent banner in 58 languages and sets Google Consent Mode v2. The free plan includes every feature. Certified by Google, IAB Europe and IAB Canada. Documentation: https://flexyconsent.com/docs/google-tag-manager/ Support: developer@flexyconsent.com", "containerContexts": [ "WEB" ] } ___TEMPLATE_PARAMETERS___ [ { "type": "CHECKBOX", "name": "loadScript", "checkboxText": "Load the FlexyConsent script from this tag", "simpleValueType": true, "defaultValue": true, "help": "Leave this ticked if Tag Manager is the only thing installed on your site. Untick it if the FlexyConsent snippet is already in your page source, above the Tag Manager container. That placement is better, because a script in the page head can hold trackers back from the first byte. This tag then sets the defaults and passes the visitor's choice to Google's tags, without loading the script." }, { "type": "TEXT", "name": "scriptId", "displayName": "Consent script ID or URL", "simpleValueType": true, "valueHint": "e.g. f39e68dab95810b0831d5fe96d9277c9f0b08a25", "valueValidators": [ { "type": "NON_EMPTY" } ], "enablingConditions": [ { "paramName": "loadScript", "paramValue": true, "type": "EQUALS" } ], "help": "Paste the 40-character ID from your Page Code in FlexyConsent (Templates > Page Code), or the whole script URL, for example https://flexyconsent.com/d/f/3/9/f39e68dab95810b0831d5fe96d9277c9f0b08a25.js. You can also use a Tag Manager variable. To try this tag without an account, enter the demo ID f39e68dab95810b0831d5fe96d9277c9f0b08a25: it shows a test banner on any website. Replace it with your own ID before you publish." }, { "type": "GROUP", "name": "consentModeGroup", "displayName": "Google Consent Mode", "groupStyle": "NO_ZIPPY", "subParams": [ { "type": "PARAM_TABLE", "name": "defaultSettings", "displayName": "Default consent state", "paramTableColumns": [ { "param": { "type": "TEXT", "name": "region", "displayName": "Region", "simpleValueType": true, "help": "Comma-separated region codes, for example DE, FR, US-CA. Leave blank to apply to every region that has no row of its own." }, "isUnique": true }, { "param": { "type": "SELECT", "name": "ad_storage", "displayName": "Ad storage", "selectItems": [ {"value": "denied", "displayValue": "Denied"}, {"value": "granted", "displayValue": "Granted"} ], "simpleValueType": true, "defaultValue": "denied" }, "isUnique": false }, { "param": { "type": "SELECT", "name": "ad_user_data", "displayName": "Ad user data", "selectItems": [ {"value": "denied", "displayValue": "Denied"}, {"value": "granted", "displayValue": "Granted"} ], "simpleValueType": true, "defaultValue": "denied" }, "isUnique": false }, { "param": { "type": "SELECT", "name": "ad_personalization", "displayName": "Ad personalization", "selectItems": [ {"value": "denied", "displayValue": "Denied"}, {"value": "granted", "displayValue": "Granted"} ], "simpleValueType": true, "defaultValue": "denied" }, "isUnique": false }, { "param": { "type": "SELECT", "name": "analytics_storage", "displayName": "Analytics storage", "selectItems": [ {"value": "denied", "displayValue": "Denied"}, {"value": "granted", "displayValue": "Granted"} ], "simpleValueType": true, "defaultValue": "denied" }, "isUnique": false }, { "param": { "type": "SELECT", "name": "functionality_storage", "displayName": "Functionality storage", "selectItems": [ {"value": "denied", "displayValue": "Denied"}, {"value": "granted", "displayValue": "Granted"} ], "simpleValueType": true, "defaultValue": "granted" }, "isUnique": false }, { "param": { "type": "SELECT", "name": "personalization_storage", "displayName": "Personalization storage", "selectItems": [ {"value": "denied", "displayValue": "Denied"}, {"value": "granted", "displayValue": "Granted"} ], "simpleValueType": true, "defaultValue": "denied" }, "isUnique": false }, { "param": { "type": "SELECT", "name": "security_storage", "displayName": "Security storage", "selectItems": [ {"value": "denied", "displayValue": "Denied"}, {"value": "granted", "displayValue": "Granted"} ], "simpleValueType": true, "defaultValue": "denied" }, "isUnique": false } ], "newRowButtonText": "Add a region", "help": "What Google's tags may do before the visitor answers the banner. Add a row to set different defaults for a region, for example US-CA. Regions without a row keep everything denied except functionality storage. A row with a blank Region replaces that default." }, { "type": "TEXT", "name": "waitForUpdate", "displayName": "Wait for update (milliseconds)", "simpleValueType": true, "defaultValue": "500", "valueValidators": [ { "type": "POSITIVE_NUMBER" } ], "help": "How long Google's tags wait for this visitor's answer before they fall back to the defaults above. Use at least 500, which usually covers a returning visitor whose browser has the script cached. Raise it towards 2000 for visitors on slow connections." }, { "type": "CHECKBOX", "name": "adsDataRedaction", "checkboxText": "Redact ads data when ad storage is denied", "simpleValueType": true, "defaultValue": true, "help": "When ad storage is denied, Google Ads and Floodlight tags remove ad click identifiers from the requests they send." }, { "type": "CHECKBOX", "name": "urlPassthrough", "checkboxText": "Pass ad click and session information in URLs when storage is denied", "simpleValueType": true, "defaultValue": false, "help": "When ad or analytics storage is denied, Google's tags can carry ad click, client ID and session ID information from page to page in your URLs, for example a gclid parameter. If you use a conversion linker tag, turn this on there too." } ] } ] ___SANDBOXED_JS_FOR_WEB_TEMPLATE___ // FlexyConsent CMP — Google Tag Manager community template. // // WHAT THIS TAG IS FOR. It puts Google Consent Mode v2 under the visitor's actual choice, from // inside Tag Manager, on the Consent Initialization trigger — so every other tag in the container // is held behind it. Optionally it also loads the FlexyConsent script itself, for sites whose only // installed code is the Tag Manager container. // // WHY IT DOES NOT USE `gtag('consent', ...)`. Google is explicit that a template must not: gtag // commands are queued behind every other pending message and can be processed after the next event // has already begun. The native setDefaultConsentState / updateConsentState APIs are ordered // correctly against the container's own event loop. FlexyConsent's page script does use the gtag // command — that is the right call from page source and the wrong one from here. // // WHERE THE ANSWER COMES FROM. `window.__fcapi` is FlexyConsent's publisher consent API. It is // defined at parse time by a stub, before the CMP bundle has loaded, and a listener registered // against that stub is adopted by the real implementation rather than dropped — so subscribing // from here is safe however early this tag runs. The stub is documented at // https://flexyconsent.com/docs/publisher-api/ and its shape is fixed at apiVersion 1.0. // // The visitor's choice reaches us as `window._fc_gcm`: the seven consent types, already // computed by the page from cookie categories OR-ed with IAB TCF purposes, most-permissive. Reading // the computed answer rather than recomputing it is deliberate — a second copy of the mapping // inside this sandbox would be free to drift from the one merchants actually see. The category // fallback below exists only for a page still serving a script from before `_fc_gcm` shipped, and // our release checks fail if it stops matching the page script. const log = require('logToConsole'); const copyFromWindow = require('copyFromWindow'); const callInWindow = require('callInWindow'); const injectScript = require('injectScript'); const setDefaultConsentState = require('setDefaultConsentState'); const updateConsentState = require('updateConsentState'); const gtagSet = require('gtagSet'); const makeNumber = require('makeNumber'); const templateStorage = require('templateStorage'); // Every type this tag is allowed to write. setDefaultConsentState and updateConsentState need write // access to EVERY key in the object they are handed, so one type missing from the access_consent // permission block fails the whole call and takes the rest of the tag down with it — including the // script injection. Keep this list and the permission block in step; our release checks fail if // they differ. const CONSENT_TYPES = [ 'ad_storage', 'ad_user_data', 'ad_personalization', 'analytics_storage', 'functionality_storage', 'personalization_storage', 'security_storage' ]; // FlexyConsent cookie category ID -> the consent types that category grants when the visitor // accepts it. Mirrors SITE_DATA_TO_GCM in the FlexyConsent page script. Category 1 (Essential) is absent because // functionality_storage is granted unconditionally, exactly as the page does it. const CATEGORY_TO_TYPES = { '2': ['analytics_storage'], '3': ['ad_storage', 'ad_user_data'], '4': ['personalization_storage'], '5': ['security_storage'] }; // ad_personalization is the one signal with an AND dependency: Marketing (3) AND Personalization // (4) must both be accepted. Mirrors SITE_DATA_AND_PAIRS. const AND_PAIRS = [{ signal: 'ad_personalization', needs: ['3', '4'] }]; const isType = (key) => CONSENT_TYPES.indexOf(key) !== -1; const isValue = (value) => value === 'granted' || value === 'denied'; // A list, without Array.isArray, which the sandbox does not provide. A string is excluded by the // typeof even though it has a length. const isList = (value) => !!value && typeof value === 'object' && typeof value.length === 'number'; const splitRegions = (raw) => { const out = []; if (typeof raw !== 'string') return out; const parts = raw.split(','); for (let i = 0; i < parts.length; i += 1) { const code = parts[i].trim().toUpperCase(); // Google documents consent regions as uppercase ISO codes. A lowercase 'fr' typed into the cell // would otherwise reach setDefaultConsentState as a region that matches nobody, silently. if (code !== '') out.push(code); } return out; }; // --------------------------------------------------------------------------- // 1. Options that do not depend on the visitor, and must precede every tag. // --------------------------------------------------------------------------- gtagSet('ads_data_redaction', data.adsDataRedaction === true); gtagSet('url_passthrough', data.urlPassthrough === true); // --------------------------------------------------------------------------- // 2. Defaults. // --------------------------------------------------------------------------- // The empty string is checked separately because makeNumber('') is 0, which would turn the grace // period off rather than fall back to it; the self-inequality is the NaN test, the sandbox having // no isNaN. A field holding only spaces is caught by the same trim. const waitRaw = (typeof data.waitForUpdate === 'string') ? data.waitForUpdate.trim() : data.waitForUpdate; const waitNum = makeNumber(waitRaw); const waitUnset = (waitRaw === undefined || waitRaw === null || waitRaw === ''); const waitUsable = (waitNum === waitNum && waitNum >= 0); if (!waitUnset && !waitUsable) { log('FlexyConsent CMP: "Wait for update" is not a non-negative number; using 500 ms.'); } const waitForUpdate = (waitUnset || !waitUsable) ? 500 : waitNum; // GTM sends nothing at all for an empty PARAM_TABLE, so a tag saved with no rows arrives here with // data.defaultSettings undefined. Iterating that would throw before the script is injected, and the // visitor would get no CMP at all — the failure would not look like a missing consent default. const defaultRows = isList(data.defaultSettings) ? data.defaultSettings : []; const applyDefaultRow = (row) => { const command = {}; for (const key in row) { if (key === 'region') { const regions = splitRegions(row[key]); if (regions.length > 0) command.region = regions; } else if (isType(key) && isValue(row[key])) { command[key] = row[key]; } // Anything else is dropped rather than forwarded. A key that is neither 'region' nor one of the // seven types would abort setDefaultConsentState for want of a permission, and with it the // whole tag. No such key exists today; this is what keeps adding a column from taking the CMP // down with it. } command.wait_for_update = waitForUpdate; setDefaultConsentState(command); }; // A row with a blank Region is the default for every region without a row of its own. let hasGlobalRow = false; for (let i = 0; i < defaultRows.length; i += 1) { if (splitRegions(defaultRows[i].region).length === 0) hasGlobalRow = true; } if (!hasGlobalRow) { // The safe baseline, and the same one the FlexyConsent page script uses: everything denied except the // functionality storage a site needs to work at all. It stays when the table holds only regional rows: // without it, one row for US-CA would leave every other region with no default from this tag. Google // applies the most specific region's default, so a row still wins in its own region. setDefaultConsentState({ ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied', analytics_storage: 'denied', functionality_storage: 'granted', personalization_storage: 'denied', security_storage: 'denied', wait_for_update: waitForUpdate }); } for (let i = 0; i < defaultRows.length; i += 1) applyDefaultRow(defaultRows[i]); // --------------------------------------------------------------------------- // 3. The visitor's own choice. // --------------------------------------------------------------------------- // What the page computed, if this script is new enough to publish it. const readPublishedSignals = () => { // The whole global, not a dotted path: copyFromWindow takes a single key. `_fc_gcm` is published // flat for exactly this reason — seven string values, nothing nested to survive the copy. const published = copyFromWindow('_fc_gcm'); if (!published || typeof published !== 'object') return undefined; const out = {}; let seen = false; for (const key in published) { if (isType(key) && isValue(published[key])) { out[key] = published[key]; seen = true; } } return seen ? out : undefined; }; // Fallback for a page still serving a generated script from before `_fc_gcm`. Derives the same // answer from the cookie categories in the event, minus the IAB TCF contribution, which only the // page can compute — a TCF visitor on an old script therefore gets the categories they chose in our // own banner, never more than they agreed to. const deriveFromCategories = (byId) => { if (!byId || typeof byId !== 'object') return undefined; const out = { ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied', analytics_storage: 'denied', functionality_storage: 'granted', personalization_storage: 'denied', security_storage: 'denied' }; for (const id in CATEGORY_TO_TYPES) { if (byId[id] !== true) continue; const granted = CATEGORY_TO_TYPES[id]; for (let i = 0; i < granted.length; i += 1) out[granted[i]] = 'granted'; } for (let p = 0; p < AND_PAIRS.length; p += 1) { const pair = AND_PAIRS[p]; let all = true; for (let r = 0; r < pair.needs.length; r += 1) { if (byId[pair.needs[r]] !== true) all = false; } if (all) out[pair.signal] = 'granted'; } return out; }; const onConsent = (event) => { // The parse-time stub answers an immediate `listenerRegistered` with no state attached — it has // none yet. Returning here is not a missed update: the bundle re-fires every adopted listener // with `consentChanged` / reason "cmpLoaded" as soon as it has the real answer. if (!event || !event.state) return; const state = event.state; // The banner is still unanswered. Saying anything now would cancel the grace period the defaults // above just asked for, and lock tags out of a decision the visitor has not made. if (state.hasChoice !== true) return; const signals = readPublishedSignals() || deriveFromCategories(state.categoriesById); if (!signals) { log('FlexyConsent CMP: a consent event arrived with no readable state; consent mode not updated.'); return; } updateConsentState(signals); }; // Once per page, not once per firing. Two listeners would each answer every change with the same // values — harmless, but it doubles the work on every mid-page withdrawal and makes the debug // console read like a loop. const subscribe = () => { if (templateStorage.getItem('fcBound') === true) return true; if (typeof copyFromWindow('__fcapi') !== 'function') return false; templateStorage.setItem('fcBound', true); callInWindow('__fcapi', 'addEventListener', onConsent); return true; }; // --------------------------------------------------------------------------- // 4. The script itself, if this tag is the one loading it. // --------------------------------------------------------------------------- // Accepts the whole snippet URL or just the ID inside it, because both are in front of the person // filling the field and neither is obviously the right one to paste. const scriptIdOf = (raw) => { if (typeof raw !== 'string') return ''; let value = raw.trim(); const slash = value.lastIndexOf('/'); if (slash !== -1) value = value.substring(slash + 1); if (value.indexOf('?') !== -1) value = value.substring(0, value.indexOf('?')); if (value.length > 3 && value.substring(value.length - 3) === '.js') { value = value.substring(0, value.length - 3); } value = value.toLowerCase(); if (value.length !== 40) return ''; for (let i = 0; i < value.length; i += 1) { if ('0123456789abcdef'.indexOf(value.substring(i, i + 1)) === -1) return ''; } return value; }; if (subscribe()) { // Already on the page — the publisher put the snippet in their source, which is the placement we // recommend. Nothing to inject. data.gtmOnSuccess(); } else if (data.loadScript !== true) { log('FlexyConsent CMP: window.__fcapi is not on this page. The consent defaults above were ' + 'applied, but nothing will ever update them. Either add the FlexyConsent snippet to your ' + 'page source ABOVE the Tag Manager container, or tick "Load the FlexyConsent script from ' + 'this tag".'); data.gtmOnSuccess(); } else { const id = scriptIdOf(data.scriptId); if (id === '') { log('FlexyConsent CMP: "Consent script ID or URL" is not a FlexyConsent script ID. Expected ' + 'the 40-character ID from your Page Code in FlexyConsent, or the whole script URL.'); data.gtmOnFailure(); } else { // The /d/a/b/c/.js fan-out: the first three characters of the ID are its directory path. const url = 'https://flexyconsent.com/d/' + id.substring(0, 1) + '/' + id.substring(1, 2) + '/' + id.substring(2, 3) + '/' + id + '.js'; injectScript(url, () => { // The script defines __fcapi synchronously as it parses, so by the time this runs the stub is // there to subscribe to. if (!subscribe()) { log('FlexyConsent CMP: the consent script loaded but did not publish window.__fcapi. ' + 'Check that the script ID belongs to a consent project.'); } data.gtmOnSuccess(); }, () => { log('FlexyConsent CMP: could not load ' + url); data.gtmOnFailure(); }, url); } } ___WEB_PERMISSIONS___ [ { "instance": { "key": { "publicId": "logging", "versionId": "1" }, "param": [ { "key": "environments", "value": { "type": 1, "string": "debug" } } ] }, "clientAnnotations": { "isEditedByUser": true }, "isRequired": true }, { "instance": { "key": { "publicId": "inject_script", "versionId": "1" }, "param": [ { "key": "urls", "value": { "type": 2, "listItem": [ { "type": 1, "string": "https://flexyconsent.com/d/*" } ] } } ] }, "clientAnnotations": { "isEditedByUser": true }, "isRequired": true }, { "instance": { "key": { "publicId": "access_globals", "versionId": "1" }, "param": [ { "key": "keys", "value": { "type": 2, "listItem": [ { "type": 3, "mapKey": [ {"type": 1, "string": "key"}, {"type": 1, "string": "read"}, {"type": 1, "string": "write"}, {"type": 1, "string": "execute"} ], "mapValue": [ {"type": 1, "string": "__fcapi"}, {"type": 8, "boolean": true}, {"type": 8, "boolean": false}, {"type": 8, "boolean": true} ] }, { "type": 3, "mapKey": [ {"type": 1, "string": "key"}, {"type": 1, "string": "read"}, {"type": 1, "string": "write"}, {"type": 1, "string": "execute"} ], "mapValue": [ {"type": 1, "string": "_fc_gcm"}, {"type": 8, "boolean": true}, {"type": 8, "boolean": false}, {"type": 8, "boolean": false} ] } ] } } ] }, "clientAnnotations": { "isEditedByUser": true }, "isRequired": true }, { "instance": { "key": { "publicId": "write_data_layer", "versionId": "1" }, "param": [ { "key": "keyPatterns", "value": { "type": 2, "listItem": [ {"type": 1, "string": "ads_data_redaction"}, {"type": 1, "string": "url_passthrough"} ] } } ] }, "clientAnnotations": { "isEditedByUser": true }, "isRequired": true }, { "instance": { "key": { "publicId": "access_template_storage", "versionId": "1" }, "param": [] }, "clientAnnotations": { "isEditedByUser": true }, "isRequired": true }, { "instance": { "key": { "publicId": "access_consent", "versionId": "1" }, "param": [ { "key": "consentTypes", "value": { "type": 2, "listItem": [ { "type": 3, "mapKey": [ {"type": 1, "string": "consentType"}, {"type": 1, "string": "read"}, {"type": 1, "string": "write"} ], "mapValue": [ {"type": 1, "string": "ad_storage"}, {"type": 8, "boolean": false}, {"type": 8, "boolean": true} ] }, { "type": 3, "mapKey": [ {"type": 1, "string": "consentType"}, {"type": 1, "string": "read"}, {"type": 1, "string": "write"} ], "mapValue": [ {"type": 1, "string": "ad_user_data"}, {"type": 8, "boolean": false}, {"type": 8, "boolean": true} ] }, { "type": 3, "mapKey": [ {"type": 1, "string": "consentType"}, {"type": 1, "string": "read"}, {"type": 1, "string": "write"} ], "mapValue": [ {"type": 1, "string": "ad_personalization"}, {"type": 8, "boolean": false}, {"type": 8, "boolean": true} ] }, { "type": 3, "mapKey": [ {"type": 1, "string": "consentType"}, {"type": 1, "string": "read"}, {"type": 1, "string": "write"} ], "mapValue": [ {"type": 1, "string": "analytics_storage"}, {"type": 8, "boolean": false}, {"type": 8, "boolean": true} ] }, { "type": 3, "mapKey": [ {"type": 1, "string": "consentType"}, {"type": 1, "string": "read"}, {"type": 1, "string": "write"} ], "mapValue": [ {"type": 1, "string": "functionality_storage"}, {"type": 8, "boolean": false}, {"type": 8, "boolean": true} ] }, { "type": 3, "mapKey": [ {"type": 1, "string": "consentType"}, {"type": 1, "string": "read"}, {"type": 1, "string": "write"} ], "mapValue": [ {"type": 1, "string": "personalization_storage"}, {"type": 8, "boolean": false}, {"type": 8, "boolean": true} ] }, { "type": 3, "mapKey": [ {"type": 1, "string": "consentType"}, {"type": 1, "string": "read"}, {"type": 1, "string": "write"} ], "mapValue": [ {"type": 1, "string": "security_storage"}, {"type": 8, "boolean": false}, {"type": 8, "boolean": true} ] } ] } } ] }, "clientAnnotations": { "isEditedByUser": true }, "isRequired": true } ] ___TESTS___ scenarios: - name: An empty defaults table still denies everything code: |- mockObject('templateStorage', freshStorage()); mock('copyFromWindow', () => undefined); runCode({ loadScript: false, waitForUpdate: '500' }); assertApi('setDefaultConsentState').wasCalledWith({ ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied', analytics_storage: 'denied', functionality_storage: 'granted', personalization_storage: 'denied', security_storage: 'denied', wait_for_update: 500 }); assertApi('gtmOnSuccess').wasCalled(); - name: A blank wait-for-update falls back to 500 rather than to 0 code: |- mockObject('templateStorage', freshStorage()); mock('copyFromWindow', () => undefined); let seen; mock('setDefaultConsentState', (command) => { seen = command; }); runCode({ loadScript: false, waitForUpdate: ' ' }); assertThat(seen.wait_for_update).isEqualTo(500); - name: A region cell becomes a list of uppercase codes code: |- mockObject('templateStorage', freshStorage()); mock('copyFromWindow', () => undefined); let seen; mock('setDefaultConsentState', (command) => { seen = command; }); runCode({ loadScript: false, waitForUpdate: '500', defaultSettings: [{ region: 'de, fr ,ES', analytics_storage: 'granted' }] }); assertThat(seen.region).isEqualTo(['DE', 'FR', 'ES']); assertThat(seen.analytics_storage).isEqualTo('granted'); - name: A row for one region leaves the denied default everywhere else code: |- mockObject('templateStorage', freshStorage()); mock('copyFromWindow', () => undefined); const calls = []; mock('setDefaultConsentState', (command) => { calls.push(command); }); runCode({ loadScript: false, waitForUpdate: '500', defaultSettings: [{ region: 'US-CA', analytics_storage: 'granted' }] }); assertThat(calls.length).isEqualTo(2); assertThat(calls[0].region).isUndefined(); assertThat(calls[0].analytics_storage).isEqualTo('denied'); assertThat(calls[0].functionality_storage).isEqualTo('granted'); assertThat(calls[0].wait_for_update).isEqualTo(500); assertThat(calls[1].region).isEqualTo(['US-CA']); assertThat(calls[1].analytics_storage).isEqualTo('granted'); - name: A row with a blank region replaces the denied default code: |- mockObject('templateStorage', freshStorage()); mock('copyFromWindow', () => undefined); const calls = []; mock('setDefaultConsentState', (command) => { calls.push(command); }); runCode({ loadScript: false, waitForUpdate: '500', defaultSettings: [ { region: 'DE', analytics_storage: 'denied' }, { region: ' ', analytics_storage: 'granted' } ] }); assertThat(calls.length).isEqualTo(2); assertThat(calls[0].region).isEqualTo(['DE']); assertThat(calls[1].region).isUndefined(); assertThat(calls[1].analytics_storage).isEqualTo('granted'); - name: The published signals reach updateConsentState code: |- mockObject('templateStorage', freshStorage()); mock('copyFromWindow', (key) => { if (key === '__fcapi') return () => {}; if (key === '_fc_gcm') return { ad_storage: 'granted', ad_user_data: 'granted', ad_personalization: 'denied', analytics_storage: 'granted', functionality_storage: 'granted', personalization_storage: 'denied', security_storage: 'denied' }; }); mock('callInWindow', (path, command, callback) => { assertThat(path).isEqualTo('__fcapi'); assertThat(command).isEqualTo('addEventListener'); callback({ eventName: 'consentChanged', state: { hasChoice: true, categoriesById: {} } }); }); runCode({ loadScript: false, waitForUpdate: '500' }); assertApi('updateConsentState').wasCalledWith({ ad_storage: 'granted', ad_user_data: 'granted', ad_personalization: 'denied', analytics_storage: 'granted', functionality_storage: 'granted', personalization_storage: 'denied', security_storage: 'denied' }); - name: An unanswered banner leaves the defaults and their grace period alone code: |- mockObject('templateStorage', freshStorage()); mock('copyFromWindow', (key) => { if (key === '__fcapi') return () => {}; if (key === '_fc_gcm') return { ad_storage: 'denied' }; }); mock('callInWindow', (path, command, callback) => { callback({ eventName: 'consentChanged', state: { hasChoice: false, categoriesById: {} } }); }); runCode({ loadScript: false, waitForUpdate: '500' }); assertApi('updateConsentState').wasNotCalled(); - name: The stub listenerRegistered event carries no state and is not an update code: |- mockObject('templateStorage', freshStorage()); mock('copyFromWindow', (key) => { if (key === '__fcapi') return () => {}; }); mock('callInWindow', (path, command, callback) => { callback({ eventName: 'listenerRegistered', listenerId: 1, cmpStatus: 'stub' }); }); runCode({ loadScript: false, waitForUpdate: '500' }); assertApi('updateConsentState').wasNotCalled(); assertApi('gtmOnSuccess').wasCalled(); - name: Without the published signals the categories give the same answer code: |- mockObject('templateStorage', freshStorage()); mock('copyFromWindow', (key) => { if (key === '__fcapi') return () => {}; // An older generated script: no _fc_gcm on the page at all. }); mock('callInWindow', (path, command, callback) => { callback({ eventName: 'consentChanged', state: { hasChoice: true, // Essential + Marketing + Personalization accepted; Analytics and Security refused. categoriesById: { '1': true, '2': false, '3': true, '4': true, '5': false } } }); }); runCode({ loadScript: false, waitForUpdate: '500' }); assertApi('updateConsentState').wasCalledWith({ ad_storage: 'granted', ad_user_data: 'granted', ad_personalization: 'granted', analytics_storage: 'denied', functionality_storage: 'granted', personalization_storage: 'granted', security_storage: 'denied' }); - name: ad_personalization needs BOTH Marketing and Personalization code: |- mockObject('templateStorage', freshStorage()); mock('copyFromWindow', (key) => { if (key === '__fcapi') return () => {}; }); mock('callInWindow', (path, command, callback) => { callback({ eventName: 'consentChanged', state: { hasChoice: true, categoriesById: { '1': true, '3': true } } }); }); let seen; mock('updateConsentState', (signals) => { seen = signals; }); runCode({ loadScript: false, waitForUpdate: '500' }); assertThat(seen.ad_storage).isEqualTo('granted'); assertThat(seen.ad_personalization).isEqualTo('denied'); - name: The script URL is built from the first three characters of the ID code: |- mockObject('templateStorage', freshStorage()); mock('copyFromWindow', () => undefined); let injected; mock('injectScript', (url, onSuccess) => { injected = url; onSuccess(); }); runCode({ loadScript: true, waitForUpdate: '500', scriptId: 'f39e68dab95810b0831d5fe96d9277c9f0b08a25' }); assertThat(injected).isEqualTo( 'https://flexyconsent.com/d/f/3/9/f39e68dab95810b0831d5fe96d9277c9f0b08a25.js'); - name: A whole snippet URL is accepted in the same field code: |- mockObject('templateStorage', freshStorage()); mock('copyFromWindow', () => undefined); let injected; mock('injectScript', (url, onSuccess) => { injected = url; onSuccess(); }); runCode({ loadScript: true, waitForUpdate: '500', scriptId: ' https://flexyconsent.com/d/f/3/9/f39e68dab95810b0831d5fe96d9277c9f0b08a25.js?v=2 ' }); assertThat(injected).isEqualTo( 'https://flexyconsent.com/d/f/3/9/f39e68dab95810b0831d5fe96d9277c9f0b08a25.js'); - name: Something that is not a script ID fails the tag instead of requesting a wrong URL code: |- mockObject('templateStorage', freshStorage()); mock('copyFromWindow', () => undefined); runCode({ loadScript: true, waitForUpdate: '500', scriptId: 'my-project' }); assertApi('injectScript').wasNotCalled(); assertApi('gtmOnFailure').wasCalled(); - name: The defaults are applied even when nothing on the page can ever update them code: |- mockObject('templateStorage', freshStorage()); mock('copyFromWindow', () => undefined); runCode({ loadScript: false, waitForUpdate: '500' }); assertApi('setDefaultConsentState').wasCalled(); assertApi('callInWindow').wasNotCalled(); assertApi('gtmOnSuccess').wasCalled(); setup: |- // templateStorage is shared by every instance of a template on a page, which is exactly what the // once-per-page subscription guard wants and exactly what a test suite does not: a scenario that // subscribes would leave the flag set for the next one, and the next one would silently assert // against a tag that never subscribed. Each scenario gets its own. const freshStorage = () => { const store = {}; return { getItem: (key) => store[key], setItem: (key, value) => { store[key] = value; }, removeItem: (key) => { store[key] = undefined; }, clear: () => {} }; }; ___NOTES___ Created on 19/09/2026