feat: initial public release

ConsentOS — a privacy-first cookie consent management platform.

Self-hosted, source-available alternative to OneTrust, Cookiebot, and
CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google
Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant
architecture with role-based access, configuration cascade
(system → org → group → site → region), dark-pattern detection in
the scanner, and a tamper-evident consent record audit trail.

This is the initial public release. Prior development history is
retained internally.

See README.md for the feature list, architecture overview, and
quick-start instructions. Licensed under the Elastic Licence 2.0 —
self-host freely; do not resell as a managed service.
This commit is contained in:
James Cottrill
2026-04-13 14:20:15 +00:00
commit fbf26453f2
341 changed files with 62807 additions and 0 deletions

View File

@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
announce,
createLiveRegion,
focusFirst,
getFocusableElements,
onEscape,
prefersReducedMotion,
trapFocus,
} from '../a11y';
describe('a11y', () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
describe('getFocusableElements', () => {
it('should find buttons', () => {
container.innerHTML = '<button>Click</button><button disabled>No</button>';
const elements = getFocusableElements(container);
expect(elements).toHaveLength(1);
expect(elements[0].tagName).toBe('BUTTON');
});
it('should find links with href', () => {
container.innerHTML = '<a href="/test">Link</a><a>No href</a>';
const elements = getFocusableElements(container);
expect(elements).toHaveLength(1);
});
it('should find inputs', () => {
container.innerHTML = '<input type="text" /><input type="checkbox" disabled />';
const elements = getFocusableElements(container);
expect(elements).toHaveLength(1);
});
it('should find elements with tabindex', () => {
container.innerHTML = '<div tabindex="0">Focusable</div><div tabindex="-1">Not focusable</div>';
const elements = getFocusableElements(container);
expect(elements).toHaveLength(1);
});
it('should return empty array for no focusable elements', () => {
container.innerHTML = '<div>Just text</div>';
const elements = getFocusableElements(container);
expect(elements).toHaveLength(0);
});
it('should search shadow DOM when present', () => {
const shadow = container.attachShadow({ mode: 'open' });
shadow.innerHTML = '<button>Shadow button</button>';
const elements = getFocusableElements(container);
expect(elements).toHaveLength(1);
});
});
describe('trapFocus', () => {
it('should wrap Tab from last to first element', () => {
container.innerHTML = '<button id="first">First</button><button id="last">Last</button>';
const first = container.querySelector('#first') as HTMLElement;
const last = container.querySelector('#last') as HTMLElement;
last.focus();
const cleanup = trapFocus(container);
const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true });
vi.spyOn(event, 'preventDefault');
// Simulate activeElement being the last element
vi.spyOn(document, 'activeElement', 'get').mockReturnValue(last);
container.dispatchEvent(event);
expect(event.preventDefault).toHaveBeenCalled();
cleanup();
});
it('should wrap Shift+Tab from first to last element', () => {
container.innerHTML = '<button id="first">First</button><button id="last">Last</button>';
const first = container.querySelector('#first') as HTMLElement;
first.focus();
const cleanup = trapFocus(container);
const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true });
vi.spyOn(event, 'preventDefault');
vi.spyOn(document, 'activeElement', 'get').mockReturnValue(first);
container.dispatchEvent(event);
expect(event.preventDefault).toHaveBeenCalled();
cleanup();
});
it('should not interfere with non-Tab keys', () => {
container.innerHTML = '<button>Btn</button>';
const cleanup = trapFocus(container);
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
vi.spyOn(event, 'preventDefault');
container.dispatchEvent(event);
expect(event.preventDefault).not.toHaveBeenCalled();
cleanup();
});
it('should return a cleanup function that removes the listener', () => {
container.innerHTML = '<button>Btn</button>';
const cleanup = trapFocus(container);
const spy = vi.spyOn(container, 'removeEventListener');
cleanup();
expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function));
});
});
describe('onEscape', () => {
it('should call callback on Escape key', () => {
const callback = vi.fn();
const cleanup = onEscape(container, callback);
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
container.dispatchEvent(event);
expect(callback).toHaveBeenCalledOnce();
cleanup();
});
it('should not call callback on other keys', () => {
const callback = vi.fn();
const cleanup = onEscape(container, callback);
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
container.dispatchEvent(event);
expect(callback).not.toHaveBeenCalled();
cleanup();
});
it('should return a cleanup function', () => {
const callback = vi.fn();
const cleanup = onEscape(container, callback);
cleanup();
// After cleanup, Escape should not trigger callback
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
container.dispatchEvent(event);
expect(callback).not.toHaveBeenCalled();
});
});
describe('focusFirst', () => {
it('should focus the first focusable element', () => {
container.innerHTML = '<div>Text</div><button id="btn">Button</button><input />';
const btn = container.querySelector('#btn') as HTMLElement;
const spy = vi.spyOn(btn, 'focus');
focusFirst(container);
expect(spy).toHaveBeenCalled();
});
it('should do nothing when no focusable elements', () => {
container.innerHTML = '<div>Just text</div>';
// Should not throw
focusFirst(container);
});
});
describe('createLiveRegion', () => {
it('should create an element with role=status', () => {
const region = createLiveRegion(container);
expect(region.getAttribute('role')).toBe('status');
expect(region.getAttribute('aria-live')).toBe('polite');
expect(region.getAttribute('aria-atomic')).toBe('true');
});
it('should append the region to the container', () => {
const region = createLiveRegion(container);
expect(container.contains(region)).toBe(true);
});
it('should have sr-only class for visual hiding', () => {
const region = createLiveRegion(container);
expect(region.className).toBe('cmp-sr-only');
});
});
describe('announce', () => {
it('should set text content on live region', async () => {
const region = createLiveRegion(container);
announce(region, 'Preferences expanded');
// The announcement happens in the next animation frame
await new Promise((resolve) => requestAnimationFrame(resolve));
expect(region.textContent).toBe('Preferences expanded');
});
it('should clear before setting to trigger re-announcement', () => {
const region = createLiveRegion(container);
region.textContent = 'Old message';
announce(region, 'New message');
// Immediately after call, text should be cleared
expect(region.textContent).toBe('');
});
});
describe('prefersReducedMotion', () => {
it('should return false by default in test environment', () => {
// JSDOM defaults to no media query match
expect(prefersReducedMotion()).toBe(false);
});
it('should check the prefers-reduced-motion media query', () => {
const matchMediaSpy = vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: true,
media: '(prefers-reduced-motion: reduce)',
} as MediaQueryList);
expect(prefersReducedMotion()).toBe(true);
matchMediaSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,368 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// We need to test banner.ts functions, but it auto-inits on import.
// We'll mock fetch and test the composed behaviour.
vi.mock('../blocker', () => ({
updateAcceptedCategories: vi.fn(),
}));
vi.mock('../consent', () => ({
buildConsentState: vi.fn(() => ({
accepted: ['necessary'],
rejected: ['analytics', 'marketing'],
visitorId: 'v-test',
consentedAt: new Date().toISOString(),
})),
readConsent: vi.fn(() => null),
writeConsent: vi.fn(),
}));
vi.mock('../gcm', () => ({
buildGcmStateFromCategories: vi.fn(() => ({
analytics_storage: 'denied',
ad_storage: 'denied',
security_storage: 'granted',
})),
updateGcm: vi.fn(),
}));
import { updateAcceptedCategories } from '../blocker';
import { buildConsentState, readConsent, writeConsent } from '../consent';
import { buildGcmStateFromCategories, updateGcm } from '../gcm';
import type { SiteConfig, CategorySlug } from '../types';
describe('banner', () => {
let mockFetch: ReturnType<typeof vi.fn>;
const ALL_CATEGORIES: CategorySlug[] = [
'necessary', 'functional', 'analytics', 'marketing', 'personalisation',
];
const NON_ESSENTIAL: CategorySlug[] = [
'functional', 'analytics', 'marketing', 'personalisation',
];
const defaultConfig: SiteConfig = {
id: 'cfg-1',
site_id: 'site-1',
blocking_mode: 'opt_in',
regional_modes: null,
tcf_enabled: false,
gpp_enabled: false,
gpp_supported_apis: [],
gpc_enabled: true,
gpc_jurisdictions: [],
gpc_global_honour: false,
gcm_enabled: true,
gcm_default: null,
shopify_privacy_enabled: false,
banner_config: null,
privacy_policy_url: null,
terms_url: null,
consent_expiry_days: 365,
consent_group_id: null,
ab_test: null,
initiator_map: null,
};
beforeEach(() => {
vi.clearAllMocks();
window.dataLayer = [];
window.__consentos = { siteId: 'site-1', apiBase: 'https://api.example.com', cdnBase: 'https://cdn.example.com', loaded: false };
mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
// Clean up banner host if it was appended
const host = document.getElementById('consentos-banner-host');
if (host) host.remove();
vi.unstubAllGlobals();
});
describe('buildDefaultConfig', () => {
it('should create a valid default config', () => {
// This mirrors what buildDefaultConfig does in the banner
const config: SiteConfig = {
id: '',
site_id: 'test-site',
blocking_mode: 'opt_in',
regional_modes: null,
tcf_enabled: false,
gpp_enabled: false,
gpp_supported_apis: [],
gpc_enabled: true,
gpc_jurisdictions: [],
gpc_global_honour: false,
gcm_enabled: true,
gcm_default: null,
shopify_privacy_enabled: false,
banner_config: null,
privacy_policy_url: null,
terms_url: null,
consent_expiry_days: 365,
consent_group_id: null,
ab_test: null,
initiator_map: null,
};
expect(config.blocking_mode).toBe('opt_in');
expect(config.gcm_enabled).toBe(true);
expect(config.consent_expiry_days).toBe(365);
});
});
describe('determineAction', () => {
it('should return accept_all when no rejections', () => {
const accepted: CategorySlug[] = ALL_CATEGORIES;
const rejected: CategorySlug[] = [];
let action: string;
if (rejected.length === 0) action = 'accept_all';
else if (accepted.length === 1 && accepted[0] === 'necessary') action = 'reject_all';
else action = 'custom';
expect(action).toBe('accept_all');
});
it('should return reject_all when only necessary accepted', () => {
const accepted: CategorySlug[] = ['necessary'];
const rejected = NON_ESSENTIAL;
let action: string;
if (rejected.length === 0) action = 'accept_all';
else if (accepted.length === 1 && accepted[0] === 'necessary') action = 'reject_all';
else action = 'custom';
expect(action).toBe('reject_all');
});
it('should return custom when partial selection', () => {
const accepted: CategorySlug[] = ['necessary', 'analytics'];
const rejected: CategorySlug[] = ['marketing', 'functional', 'personalisation'];
let action: string;
if (rejected.length === 0) action = 'accept_all';
else if (accepted.length === 1 && accepted[0] === 'necessary') action = 'reject_all';
else action = 'custom';
expect(action).toBe('custom');
});
});
describe('handleConsent flow', () => {
it('should write consent, update blocker, and update GCM', () => {
const accepted: CategorySlug[] = ['necessary', 'analytics'];
const rejected: CategorySlug[] = ['marketing', 'functional', 'personalisation'];
const gcmState = buildGcmStateFromCategories(accepted);
const state = buildConsentState(accepted, rejected);
writeConsent(state, defaultConfig.consent_expiry_days);
updateAcceptedCategories(accepted);
if (defaultConfig.gcm_enabled) {
updateGcm(gcmState);
}
expect(writeConsent).toHaveBeenCalled();
expect(updateAcceptedCategories).toHaveBeenCalledWith(accepted);
expect(updateGcm).toHaveBeenCalled();
});
it('should NOT call updateGcm when gcm_enabled is false', () => {
const config = { ...defaultConfig, gcm_enabled: false };
const accepted: CategorySlug[] = ['necessary'];
const rejected = NON_ESSENTIAL;
const gcmState = buildGcmStateFromCategories(accepted);
const state = buildConsentState(accepted, rejected);
writeConsent(state, config.consent_expiry_days);
updateAcceptedCategories(accepted);
if (config.gcm_enabled) {
updateGcm(gcmState);
}
expect(updateGcm).not.toHaveBeenCalled();
});
it('should post consent to the API', () => {
const accepted: CategorySlug[] = ALL_CATEGORIES;
const rejected: CategorySlug[] = [];
mockFetch.mockResolvedValue(new Response('', { status: 201 }));
fetch('https://api.example.com/api/v1/consent/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
site_id: 'site-1',
visitor_id: 'v-test',
action: 'accept_all',
categories_accepted: accepted,
categories_rejected: rejected,
gcm_state: { analytics_storage: 'granted' },
page_url: window.location.href,
}),
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/api/v1/consent/',
expect.objectContaining({ method: 'POST' })
);
});
it('should dispatch consent-change event', () => {
const accepted: CategorySlug[] = ['necessary', 'functional'];
let receivedDetail: unknown = null;
document.addEventListener('consentos:consent-change', ((e: CustomEvent) => {
receivedDetail = e.detail;
}) as EventListener);
document.dispatchEvent(
new CustomEvent('consentos:consent-change', { detail: { accepted } })
);
expect(receivedDetail).toEqual({ accepted });
});
});
describe('renderBanner', () => {
it('should create a shadow DOM host element', () => {
const host = document.createElement('div');
host.id = 'consentos-banner-host';
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<div class="consentos-banner" role="dialog" aria-label="Cookie consent">
<button data-action="accept">Accept all</button>
<button data-action="reject">Reject all</button>
</div>
`;
document.body.appendChild(host);
const bannerHost = document.getElementById('consentos-banner-host');
expect(bannerHost).not.toBeNull();
expect(bannerHost?.shadowRoot).not.toBeNull();
const banner = bannerHost?.shadowRoot?.querySelector('.consentos-banner');
expect(banner).not.toBeNull();
expect(banner?.getAttribute('role')).toBe('dialog');
});
it('should render category toggles', () => {
const categories = [
{ slug: 'necessary', name: 'Necessary', locked: true },
{ slug: 'analytics', name: 'Analytics', locked: false },
];
const html = categories
.map(
(cat) =>
`<label class="cmp-category">
<span>${cat.name}</span>
<input type="checkbox" data-category="${cat.slug}" ${cat.locked ? 'checked disabled' : ''} />
</label>`
)
.join('');
const div = document.createElement('div');
div.innerHTML = html;
const inputs = div.querySelectorAll<HTMLInputElement>('input[data-category]');
expect(inputs).toHaveLength(2);
expect(inputs[0].disabled).toBe(true);
expect(inputs[0].checked).toBe(true);
expect(inputs[1].disabled).toBe(false);
});
it('should include privacy policy link when URL is provided', () => {
const url = 'https://example.com/privacy';
const html = `<a href="${url}" target="_blank" rel="noopener">Privacy Policy</a>`;
const div = document.createElement('div');
div.innerHTML = html;
const link = div.querySelector('a');
expect(link?.href).toBe(url);
expect(link?.target).toBe('_blank');
expect(link?.rel).toBe('noopener');
});
});
describe('removeBanner', () => {
it('should remove the banner host from DOM', async () => {
const host = document.createElement('div');
host.id = 'consentos-banner-host';
document.body.appendChild(host);
expect(document.getElementById('consentos-banner-host')).not.toBeNull();
host.remove();
expect(document.getElementById('consentos-banner-host')).toBeNull();
});
});
describe('getBannerStyles', () => {
it('should use default colours when no banner_config', () => {
const bc = defaultConfig.banner_config;
const bg = bc?.backgroundColour ?? '#ffffff';
const text = bc?.textColour ?? '#0E1929';
const primary = bc?.primaryColour ?? '#2C6AE4';
expect(bg).toBe('#ffffff');
expect(text).toBe('#0E1929');
expect(primary).toBe('#2C6AE4');
});
it('should use custom colours from banner_config', () => {
const config = {
...defaultConfig,
banner_config: {
backgroundColour: '#000000',
textColour: '#ffffff',
primaryColour: '#ff0000',
},
};
const bc = config.banner_config;
const bg = bc?.backgroundColour ?? '#ffffff';
const text = bc?.textColour ?? '#0E1929';
const primary = bc?.primaryColour ?? '#ff0000';
expect(bg).toBe('#000000');
expect(text).toBe('#ffffff');
expect(primary).toBe('#ff0000');
});
});
describe('getSelectedCategories', () => {
it('should return necessary plus checked categories', () => {
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<input type="checkbox" data-category="necessary" checked disabled />
<input type="checkbox" data-category="analytics" checked />
<input type="checkbox" data-category="marketing" />
`;
const checked: CategorySlug[] = ['necessary'];
shadow.querySelectorAll<HTMLInputElement>('input[data-category]').forEach((input) => {
if (input.checked) {
checked.push(input.getAttribute('data-category') as CategorySlug);
}
});
const unique = [...new Set(checked)];
expect(unique).toContain('necessary');
expect(unique).toContain('analytics');
expect(unique).not.toContain('marketing');
});
});
});

View File

@@ -0,0 +1,333 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
installBlocker,
uninstallBlocker,
updateAcceptedCategories,
getBlockedCount,
isCategoryAllowed,
addScriptPatterns,
loadInitiatorMappings,
} from '../blocker';
describe('blocker', () => {
beforeEach(() => {
installBlocker();
});
afterEach(() => {
uninstallBlocker();
});
describe('installBlocker', () => {
it('should only install once', () => {
// Install a second time — should be a no-op
installBlocker();
expect(isCategoryAllowed('necessary')).toBe(true);
});
it('should allow necessary category by default', () => {
expect(isCategoryAllowed('necessary')).toBe(true);
});
it('should deny non-essential categories by default', () => {
expect(isCategoryAllowed('analytics')).toBe(false);
expect(isCategoryAllowed('marketing')).toBe(false);
expect(isCategoryAllowed('functional')).toBe(false);
expect(isCategoryAllowed('personalisation')).toBe(false);
});
});
describe('updateAcceptedCategories', () => {
it('should update accepted categories', () => {
updateAcceptedCategories(['necessary', 'analytics']);
expect(isCategoryAllowed('analytics')).toBe(true);
expect(isCategoryAllowed('marketing')).toBe(false);
});
it('should accept all categories when all are provided', () => {
updateAcceptedCategories([
'necessary',
'functional',
'analytics',
'marketing',
'personalisation',
]);
expect(isCategoryAllowed('functional')).toBe(true);
expect(isCategoryAllowed('analytics')).toBe(true);
expect(isCategoryAllowed('marketing')).toBe(true);
expect(isCategoryAllowed('personalisation')).toBe(true);
});
});
describe('script interception via createElement', () => {
it('should override document.createElement', () => {
// createElement should still work for non-script elements
const div = document.createElement('div');
expect(div).toBeInstanceOf(HTMLDivElement);
});
it('should create script elements normally when no src is set', () => {
const script = document.createElement('script');
expect(script).toBeInstanceOf(HTMLScriptElement);
expect(script.hasAttribute('data-consentos-blocked')).toBe(false);
});
it('should mark analytics scripts as blocked when src is set', () => {
const script = document.createElement('script') as HTMLScriptElement;
script.src = 'https://www.google-analytics.com/analytics.js';
expect(script.getAttribute('data-consentos-blocked')).toBe('true');
expect(script.getAttribute('data-consentos-category')).toBe('analytics');
expect(script.type).toBe('text/blocked');
});
it('should mark marketing scripts as blocked', () => {
const script = document.createElement('script') as HTMLScriptElement;
script.src = 'https://connect.facebook.net/en_US/fbevents.js';
expect(script.getAttribute('data-consentos-blocked')).toBe('true');
expect(script.getAttribute('data-consentos-category')).toBe('marketing');
});
it('should allow scripts when their category is accepted', () => {
updateAcceptedCategories(['necessary', 'analytics']);
const script = document.createElement('script') as HTMLScriptElement;
script.src = 'https://www.google-analytics.com/analytics.js';
// Should not be blocked
expect(script.hasAttribute('data-consentos-blocked')).toBe(false);
});
it('should allow scripts with unknown src (no matching pattern)', () => {
const script = document.createElement('script') as HTMLScriptElement;
script.src = 'https://example.com/my-custom-script.js';
expect(script.hasAttribute('data-consentos-blocked')).toBe(false);
});
it('should respect explicit data-category attribute', () => {
const script = document.createElement('script') as HTMLScriptElement;
script.setAttribute('data-category', 'marketing');
script.src = 'https://example.com/unknown-tracker.js';
expect(script.getAttribute('data-consentos-blocked')).toBe('true');
expect(script.getAttribute('data-consentos-category')).toBe('marketing');
});
});
describe('MutationObserver blocking', () => {
it('should block a script inserted into the DOM', async () => {
const script = document.createElement('script') as HTMLScriptElement;
script.setAttribute('data-category', 'analytics');
script.src = 'https://example.com/analytics.js';
document.head.appendChild(script);
// MutationObserver is async, wait a tick
await new Promise((resolve) => setTimeout(resolve, 0));
// Script should have been removed from DOM and queued
expect(script.parentNode).toBeNull();
expect(getBlockedCount()).toBeGreaterThan(0);
});
it('should not block scripts marked as allowed', async () => {
const script = document.createElement('script') as HTMLScriptElement;
script.setAttribute('data-consentos-allowed', 'true');
script.setAttribute('data-category', 'analytics');
script.textContent = '/* allowed */';
document.head.appendChild(script);
await new Promise((resolve) => setTimeout(resolve, 0));
// Should still be in the DOM
expect(script.parentNode).toBe(document.head);
// Clean up
script.remove();
});
});
describe('release manager', () => {
it('should release blocked scripts when consent is granted', async () => {
// Insert a blocked script
const script = document.createElement('script') as HTMLScriptElement;
script.setAttribute('data-category', 'analytics');
script.src = 'https://example.com/analytics-lib.js';
document.head.appendChild(script);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(getBlockedCount()).toBeGreaterThan(0);
const countBefore = getBlockedCount();
// Grant analytics consent
updateAcceptedCategories(['necessary', 'analytics']);
// Blocked count should decrease
expect(getBlockedCount()).toBeLessThan(countBefore);
});
it('should not release scripts for non-consented categories', async () => {
const script = document.createElement('script') as HTMLScriptElement;
script.setAttribute('data-category', 'marketing');
script.src = 'https://example.com/marketing.js';
document.head.appendChild(script);
await new Promise((resolve) => setTimeout(resolve, 0));
const count = getBlockedCount();
// Grant analytics only (not marketing)
updateAcceptedCategories(['necessary', 'analytics']);
// Marketing scripts should still be blocked
// Count may have decreased by analytics scripts but marketing should remain
expect(getBlockedCount()).toBeGreaterThanOrEqual(count > 0 ? 1 : 0);
});
});
describe('cookie proxy', () => {
it('should allow CMP cookies to be set', () => {
document.cookie = '_consentos_test=value; path=/';
expect(document.cookie).toContain('_consentos_test=value');
// Clean up
document.cookie = '_consentos_test=; expires=Thu, 01 Jan 1970 00:00:00 GMT';
});
it('should block known analytics cookies when not consented', () => {
const before = document.cookie;
document.cookie = '_ga=GA1.2.12345; path=/';
// _ga should not appear (blocked)
expect(document.cookie).not.toContain('_ga=GA1.2.12345');
// Ensure we haven't corrupted the cookie string
expect(document.cookie.length).toBeGreaterThanOrEqual(0);
});
it('should allow analytics cookies when consented', () => {
updateAcceptedCategories(['necessary', 'analytics']);
document.cookie = '_ga=GA1.2.12345; path=/';
expect(document.cookie).toContain('_ga=GA1.2.12345');
// Clean up
document.cookie = '_ga=; expires=Thu, 01 Jan 1970 00:00:00 GMT';
});
it('should allow unknown cookies (not in any pattern)', () => {
document.cookie = 'my_app_session=abc123; path=/';
expect(document.cookie).toContain('my_app_session=abc123');
// Clean up
document.cookie = 'my_app_session=; expires=Thu, 01 Jan 1970 00:00:00 GMT';
});
});
describe('storage proxy', () => {
it('should block known analytics storage keys', () => {
localStorage.setItem('_hjSession_12345', 'test');
// Should be blocked — key should not exist
expect(localStorage.getItem('_hjSession_12345')).toBeNull();
});
it('should allow storage writes when category is consented', () => {
updateAcceptedCategories(['necessary', 'analytics']);
localStorage.setItem('_hjSession_12345', 'test');
expect(localStorage.getItem('_hjSession_12345')).toBe('test');
// Clean up
localStorage.removeItem('_hjSession_12345');
});
it('should allow CMP storage keys', () => {
localStorage.setItem('_consentos_state', 'test');
expect(localStorage.getItem('_consentos_state')).toBe('test');
// Clean up
localStorage.removeItem('_consentos_state');
});
it('should allow unknown storage keys', () => {
localStorage.setItem('my_app_key', 'value');
expect(localStorage.getItem('my_app_key')).toBe('value');
// Clean up
localStorage.removeItem('my_app_key');
});
});
describe('addScriptPatterns', () => {
it('should add custom patterns for classification', () => {
addScriptPatterns([
{ pattern: 'custom-tracker\\.example\\.com', category: 'marketing' },
]);
const script = document.createElement('script') as HTMLScriptElement;
script.src = 'https://custom-tracker.example.com/track.js';
expect(script.getAttribute('data-consentos-blocked')).toBe('true');
expect(script.getAttribute('data-consentos-category')).toBe('marketing');
});
it('should handle invalid patterns gracefully', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
addScriptPatterns([{ pattern: '[invalid', category: 'analytics' }]);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid script pattern')
);
consoleSpy.mockRestore();
});
});
describe('loadInitiatorMappings', () => {
it('should block scripts matching initiator mappings', () => {
loadInitiatorMappings([
{ root_script: 'gtm\\.example\\.com', category: 'marketing' },
]);
const script = document.createElement('script') as HTMLScriptElement;
script.src = 'https://gtm.example.com/gtm.js';
expect(script.getAttribute('data-consentos-blocked')).toBe('true');
expect(script.getAttribute('data-consentos-category')).toBe('marketing');
});
it('should not block initiator scripts when category is consented', () => {
updateAcceptedCategories(['necessary', 'marketing']);
loadInitiatorMappings([
{ root_script: 'gtm\\.example\\.com', category: 'marketing' },
]);
const script = document.createElement('script') as HTMLScriptElement;
script.src = 'https://gtm.example.com/gtm.js';
expect(script.hasAttribute('data-consentos-blocked')).toBe(false);
});
it('should handle invalid initiator patterns gracefully', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
loadInitiatorMappings([{ root_script: '[invalid', category: 'analytics' }]);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid initiator pattern')
);
consoleSpy.mockRestore();
});
it('should prioritise URL patterns over initiator mappings', () => {
// google-analytics.com matches the built-in analytics pattern
loadInitiatorMappings([
{ root_script: 'google-analytics\\.com', category: 'marketing' },
]);
const script = document.createElement('script') as HTMLScriptElement;
script.src = 'https://www.google-analytics.com/analytics.js';
// Should match URL pattern (analytics) not initiator mapping (marketing)
expect(script.getAttribute('data-consentos-category')).toBe('analytics');
});
});
describe('uninstallBlocker', () => {
it('should restore original document.createElement', () => {
uninstallBlocker();
const div = document.createElement('div');
expect(div).toBeInstanceOf(HTMLDivElement);
});
it('should reset blocked count to zero', () => {
uninstallBlocker();
expect(getBlockedCount()).toBe(0);
});
it('should reset accepted categories to necessary only', () => {
updateAcceptedCategories(['necessary', 'analytics', 'marketing']);
uninstallBlocker();
expect(isCategoryAllowed('analytics')).toBe(false);
expect(isCategoryAllowed('necessary')).toBe(true);
});
});
});

View File

@@ -0,0 +1,104 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
buildConsentState,
clearConsent,
generateVisitorId,
hasConsent,
isCategoryAccepted,
readConsent,
writeConsent,
} from '../consent';
describe('consent', () => {
beforeEach(() => {
// Clear all cookies
document.cookie.split(';').forEach((c) => {
const name = c.split('=')[0].trim();
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
});
});
describe('generateVisitorId', () => {
it('should return a UUID-like string', () => {
const id = generateVisitorId();
expect(id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
);
});
it('should generate unique IDs', () => {
const id1 = generateVisitorId();
const id2 = generateVisitorId();
expect(id1).not.toBe(id2);
});
});
describe('buildConsentState', () => {
it('should build state with accepted and rejected categories', () => {
const state = buildConsentState(
['necessary', 'analytics'],
['marketing'],
);
expect(state.accepted).toEqual(['necessary', 'analytics']);
expect(state.rejected).toEqual(['marketing']);
expect(state.visitorId).toBeTruthy();
expect(state.bannerVersion).toBe('0.1.0');
expect(state.consentedAt).toBeTruthy();
});
it('should use existing visitor ID if provided', () => {
const state = buildConsentState(
['necessary'],
[],
'existing-id-123',
);
expect(state.visitorId).toBe('existing-id-123');
});
});
describe('read/write/clear consent', () => {
it('should return null when no consent cookie exists', () => {
expect(readConsent()).toBeNull();
expect(hasConsent()).toBe(false);
});
it('should write and read consent state', () => {
const state = buildConsentState(['necessary', 'analytics'], ['marketing']);
writeConsent(state);
const read = readConsent();
expect(read).not.toBeNull();
expect(read!.accepted).toEqual(['necessary', 'analytics']);
expect(read!.rejected).toEqual(['marketing']);
expect(hasConsent()).toBe(true);
});
it('should clear consent', () => {
const state = buildConsentState(['necessary'], []);
writeConsent(state);
expect(hasConsent()).toBe(true);
clearConsent();
expect(hasConsent()).toBe(false);
});
});
describe('isCategoryAccepted', () => {
it('should return true for necessary when no consent exists', () => {
expect(isCategoryAccepted('necessary')).toBe(true);
});
it('should return false for non-necessary when no consent exists', () => {
expect(isCategoryAccepted('analytics')).toBe(false);
});
it('should check accepted categories from cookie', () => {
const state = buildConsentState(['necessary', 'analytics'], ['marketing']);
writeConsent(state);
expect(isCategoryAccepted('necessary')).toBe(true);
expect(isCategoryAccepted('analytics')).toBe(true);
expect(isCategoryAccepted('marketing')).toBe(false);
});
});
});

View File

@@ -0,0 +1,155 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
buildDeniedDefaults,
buildGcmStateFromCategories,
setGcmDefaults,
updateGcm,
} from '../gcm';
describe('gcm', () => {
beforeEach(() => {
// Reset dataLayer and gtag before each test
window.dataLayer = [];
// @ts-expect-error — resetting gtag for test isolation
window.gtag = undefined;
});
describe('buildDeniedDefaults', () => {
it('should deny all except security_storage', () => {
const defaults = buildDeniedDefaults();
expect(defaults.ad_storage).toBe('denied');
expect(defaults.ad_user_data).toBe('denied');
expect(defaults.ad_personalization).toBe('denied');
expect(defaults.analytics_storage).toBe('denied');
expect(defaults.functionality_storage).toBe('denied');
expect(defaults.personalization_storage).toBe('denied');
expect(defaults.security_storage).toBe('granted');
});
it('should return all 7 consent types', () => {
const defaults = buildDeniedDefaults();
const keys = Object.keys(defaults);
expect(keys).toHaveLength(7);
});
});
describe('buildGcmStateFromCategories', () => {
it('should grant analytics_storage for analytics category', () => {
const state = buildGcmStateFromCategories(['necessary', 'analytics']);
expect(state.analytics_storage).toBe('granted');
expect(state.ad_storage).toBe('denied');
});
it('should grant ad types for marketing category', () => {
const state = buildGcmStateFromCategories(['necessary', 'marketing']);
expect(state.ad_storage).toBe('granted');
expect(state.ad_user_data).toBe('granted');
expect(state.ad_personalization).toBe('granted');
expect(state.analytics_storage).toBe('denied');
});
it('should grant functionality for functional category', () => {
const state = buildGcmStateFromCategories(['necessary', 'functional']);
expect(state.functionality_storage).toBe('granted');
expect(state.personalization_storage).toBe('granted');
});
it('should grant personalization_storage for personalisation category', () => {
const state = buildGcmStateFromCategories(['necessary', 'personalisation']);
expect(state.personalization_storage).toBe('granted');
expect(state.functionality_storage).toBe('denied');
});
it('should grant all for all categories', () => {
const state = buildGcmStateFromCategories([
'necessary', 'functional', 'analytics', 'marketing', 'personalisation',
]);
expect(state.analytics_storage).toBe('granted');
expect(state.ad_storage).toBe('granted');
expect(state.ad_user_data).toBe('granted');
expect(state.ad_personalization).toBe('granted');
expect(state.functionality_storage).toBe('granted');
expect(state.personalization_storage).toBe('granted');
expect(state.security_storage).toBe('granted');
});
it('should deny all for only necessary', () => {
const state = buildGcmStateFromCategories(['necessary']);
expect(state.analytics_storage).toBe('denied');
expect(state.ad_storage).toBe('denied');
expect(state.functionality_storage).toBe('denied');
expect(state.security_storage).toBe('granted');
});
it('should handle empty array', () => {
const state = buildGcmStateFromCategories([]);
expect(state.analytics_storage).toBe('denied');
expect(state.ad_storage).toBe('denied');
expect(state.security_storage).toBe('granted');
});
});
describe('setGcmDefaults', () => {
it('should create dataLayer if it does not exist', () => {
// @ts-expect-error — simulating no dataLayer
delete window.dataLayer;
setGcmDefaults(buildDeniedDefaults());
expect(window.dataLayer).toBeDefined();
expect(Array.isArray(window.dataLayer)).toBe(true);
});
it('should push consent default command to dataLayer', () => {
setGcmDefaults(buildDeniedDefaults());
// The gtag function pushes arguments objects to dataLayer
expect(window.dataLayer.length).toBeGreaterThan(0);
});
it('should create gtag function if not present', () => {
setGcmDefaults(buildDeniedDefaults());
expect(typeof window.gtag).toBe('function');
});
it('should include wait_for_update parameter', () => {
setGcmDefaults(buildDeniedDefaults());
// The first entry should be the consent default call
// gtag pushes `arguments` objects; check the dataLayer
const entry = window.dataLayer[0] as { [key: number]: unknown };
// Arguments object: [0]='consent', [1]='default', [2]={...}
expect(entry[0]).toBe('consent');
expect(entry[1]).toBe('default');
const consentObj = entry[2] as Record<string, unknown>;
expect(consentObj.wait_for_update).toBe(500);
});
});
describe('updateGcm', () => {
it('should push consent update command to dataLayer', () => {
// Initialise gtag first
setGcmDefaults(buildDeniedDefaults());
const lengthBefore = window.dataLayer.length;
updateGcm({ analytics_storage: 'granted' });
expect(window.dataLayer.length).toBe(lengthBefore + 1);
const entry = window.dataLayer[window.dataLayer.length - 1] as { [key: number]: unknown };
expect(entry[0]).toBe('consent');
expect(entry[1]).toBe('update');
});
it('should work without prior setGcmDefaults call', () => {
updateGcm({ ad_storage: 'granted' });
expect(window.dataLayer.length).toBeGreaterThan(0);
});
it('should pass the state object correctly', () => {
setGcmDefaults(buildDeniedDefaults());
const state = { analytics_storage: 'granted' as const, ad_storage: 'denied' as const };
updateGcm(state);
const entry = window.dataLayer[window.dataLayer.length - 1] as { [key: number]: unknown };
const consentObj = entry[2] as Record<string, string>;
expect(consentObj.analytics_storage).toBe('granted');
expect(consentObj.ad_storage).toBe('denied');
});
});
});

View File

@@ -0,0 +1,226 @@
/**
* Tests for GPC signal detection and jurisdiction-aware auto-opt-out.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
type GpcConfig,
type GpcResult,
isGpcEnabled,
shouldHonourGpc,
evaluateGpc,
DEFAULT_GPC_JURISDICTIONS,
GPC_OPTOUT_CATEGORIES,
GPC_ACCEPTED_CATEGORIES,
} from '../gpc';
// ── Helpers ───────────────────────────────────────────────────────────
function makeGpcConfig(overrides: Partial<GpcConfig> = {}): GpcConfig {
return {
gpc_enabled: true,
gpc_jurisdictions: [],
gpc_global_honour: false,
...overrides,
};
}
/** Set or clear the GPC signal on navigator. */
function setGpc(value: boolean | undefined): void {
if (value === undefined) {
delete (navigator as { globalPrivacyControl?: boolean }).globalPrivacyControl;
} else {
Object.defineProperty(navigator, 'globalPrivacyControl', {
value,
writable: true,
configurable: true,
});
}
}
// ── Tests ─────────────────────────────────────────────────────────────
describe('GPC Signal Detection', () => {
afterEach(() => {
setGpc(undefined);
});
// ── isGpcEnabled ──────────────────────────────────────────────────
describe('isGpcEnabled', () => {
it('returns true when navigator.globalPrivacyControl is true', () => {
setGpc(true);
expect(isGpcEnabled()).toBe(true);
});
it('returns false when navigator.globalPrivacyControl is false', () => {
setGpc(false);
expect(isGpcEnabled()).toBe(false);
});
it('returns false when navigator.globalPrivacyControl is undefined', () => {
setGpc(undefined);
expect(isGpcEnabled()).toBe(false);
});
});
// ── shouldHonourGpc ───────────────────────────────────────────────
describe('shouldHonourGpc', () => {
it('returns false when gpc_enabled is false', () => {
const config = makeGpcConfig({ gpc_enabled: false });
expect(shouldHonourGpc('US-CA', config)).toBe(false);
});
it('returns true when gpc_global_honour is true regardless of region', () => {
const config = makeGpcConfig({ gpc_global_honour: true });
expect(shouldHonourGpc('DE', config)).toBe(true);
expect(shouldHonourGpc(null, config)).toBe(true);
});
it('returns true for default jurisdiction US-CA', () => {
const config = makeGpcConfig();
expect(shouldHonourGpc('US-CA', config)).toBe(true);
});
it('returns true for default jurisdiction US-CO', () => {
const config = makeGpcConfig();
expect(shouldHonourGpc('US-CO', config)).toBe(true);
});
it('returns true for default jurisdiction US-CT', () => {
const config = makeGpcConfig();
expect(shouldHonourGpc('US-CT', config)).toBe(true);
});
it('returns true for default jurisdiction US-TX', () => {
const config = makeGpcConfig();
expect(shouldHonourGpc('US-TX', config)).toBe(true);
});
it('returns true for default jurisdiction US-MT', () => {
const config = makeGpcConfig();
expect(shouldHonourGpc('US-MT', config)).toBe(true);
});
it('returns false for non-GPC jurisdiction', () => {
const config = makeGpcConfig();
expect(shouldHonourGpc('US-NY', config)).toBe(false);
});
it('returns false for null region', () => {
const config = makeGpcConfig();
expect(shouldHonourGpc(null, config)).toBe(false);
});
it('uses custom jurisdictions when provided', () => {
const config = makeGpcConfig({
gpc_jurisdictions: ['GB', 'DE'],
});
expect(shouldHonourGpc('GB', config)).toBe(true);
expect(shouldHonourGpc('DE', config)).toBe(true);
expect(shouldHonourGpc('US-CA', config)).toBe(false);
});
});
// ── evaluateGpc ───────────────────────────────────────────────────
describe('evaluateGpc', () => {
it('returns detected=false when GPC is not set', () => {
setGpc(undefined);
const config = makeGpcConfig();
const result = evaluateGpc(config, 'US-CA');
expect(result.detected).toBe(false);
expect(result.honoured).toBe(false);
});
it('returns detected=true, honoured=true in California', () => {
setGpc(true);
const config = makeGpcConfig();
const result = evaluateGpc(config, 'US-CA');
expect(result.detected).toBe(true);
expect(result.honoured).toBe(true);
expect(result.region).toBe('US-CA');
});
it('returns detected=true, honoured=false in non-GPC state', () => {
setGpc(true);
const config = makeGpcConfig();
const result = evaluateGpc(config, 'US-NY');
expect(result.detected).toBe(true);
expect(result.honoured).toBe(false);
});
it('returns detected=true, honoured=false when gpc_enabled is false', () => {
setGpc(true);
const config = makeGpcConfig({ gpc_enabled: false });
const result = evaluateGpc(config, 'US-CA');
expect(result.detected).toBe(true);
expect(result.honoured).toBe(false);
});
it('returns honoured=true with gpc_global_honour regardless of region', () => {
setGpc(true);
const config = makeGpcConfig({ gpc_global_honour: true });
const result = evaluateGpc(config, 'FR');
expect(result.detected).toBe(true);
expect(result.honoured).toBe(true);
});
it('passes null region through', () => {
setGpc(true);
const config = makeGpcConfig();
const result = evaluateGpc(config, null);
expect(result.region).toBeNull();
expect(result.honoured).toBe(false);
});
it('defaults region to null when not provided', () => {
setGpc(true);
const config = makeGpcConfig({ gpc_global_honour: true });
const result = evaluateGpc(config);
expect(result.region).toBeNull();
expect(result.honoured).toBe(true);
});
});
// ── Constants ─────────────────────────────────────────────────────
describe('constants', () => {
it('DEFAULT_GPC_JURISDICTIONS includes all five required states', () => {
expect(DEFAULT_GPC_JURISDICTIONS).toContain('US-CA');
expect(DEFAULT_GPC_JURISDICTIONS).toContain('US-CO');
expect(DEFAULT_GPC_JURISDICTIONS).toContain('US-CT');
expect(DEFAULT_GPC_JURISDICTIONS).toContain('US-TX');
expect(DEFAULT_GPC_JURISDICTIONS).toContain('US-MT');
expect(DEFAULT_GPC_JURISDICTIONS).toHaveLength(5);
});
it('GPC_OPTOUT_CATEGORIES includes marketing and personalisation', () => {
expect(GPC_OPTOUT_CATEGORIES).toContain('marketing');
expect(GPC_OPTOUT_CATEGORIES).toContain('personalisation');
});
it('GPC_ACCEPTED_CATEGORIES includes necessary, functional, analytics', () => {
expect(GPC_ACCEPTED_CATEGORIES).toContain('necessary');
expect(GPC_ACCEPTED_CATEGORIES).toContain('functional');
expect(GPC_ACCEPTED_CATEGORIES).toContain('analytics');
});
it('GPC opt-out and accepted categories cover all categories', () => {
const all = [...GPC_ACCEPTED_CATEGORIES, ...GPC_OPTOUT_CATEGORIES];
expect(all).toContain('necessary');
expect(all).toContain('functional');
expect(all).toContain('analytics');
expect(all).toContain('marketing');
expect(all).toContain('personalisation');
});
});
});

View File

@@ -0,0 +1,501 @@
/**
* Tests for the GPP CMP API (__gpp() global function).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
installGppApi,
removeGppApi,
updateGppConsent,
setGppDisplayStatus,
setGppSignalStatus,
getGppString,
isGppApiInstalled,
type GppPingReturn,
type GppData,
type GppEventData,
type GppApiCallback,
} from '../gpp-api';
import {
type GppString,
createDefaultSectionData,
US_NATIONAL,
US_CALIFORNIA,
SECTION_REGISTRY,
} from '../gpp';
// Section IDs (US_NATIONAL and US_CALIFORNIA are SectionDef objects, not numbers)
const US_NATIONAL_ID = US_NATIONAL.id; // 7
const US_CALIFORNIA_ID = US_CALIFORNIA.id; // 8
// ── Helpers ───────────────────────────────────────────────────────────
/** Call __gpp and return the callback args as a promise. */
function callGpp(
command: string,
parameter?: unknown,
): Promise<{ data: unknown; success: boolean }> {
return new Promise((resolve) => {
window.__gpp!(command, (data: unknown, success: boolean) => {
resolve({ data, success });
}, parameter);
});
}
/** Build a minimal GppString for testing. */
function buildTestGpp(sectionIds: number[] = [US_NATIONAL_ID]): GppString {
const sections = new Map<number, { data: Record<string, number | number[]> }>();
for (const id of sectionIds) {
const def = SECTION_REGISTRY.get(id);
if (def) {
const data = createDefaultSectionData(def);
sections.set(id, { data });
}
}
return {
header: {
version: 1,
sectionIds,
applicableSections: sectionIds,
},
sections,
};
}
// ── Tests ─────────────────────────────────────────────────────────────
describe('GPP CMP API', () => {
beforeEach(() => {
removeGppApi();
});
afterEach(() => {
removeGppApi();
});
// ── Installation ──────────────────────────────────────────────────
describe('installGppApi / removeGppApi', () => {
it('installs __gpp on window', () => {
expect(window.__gpp).toBeUndefined();
installGppApi(42, ['usnat']);
expect(typeof window.__gpp).toBe('function');
expect(isGppApiInstalled()).toBe(true);
});
it('removes __gpp from window', () => {
installGppApi(42);
removeGppApi();
expect(window.__gpp).toBeUndefined();
expect(isGppApiInstalled()).toBe(false);
});
it('clears __gppQueue on remove', () => {
(window as { __gppQueue?: unknown[] }).__gppQueue = [['ping', vi.fn()]];
installGppApi(42);
removeGppApi();
expect((window as { __gppQueue?: unknown[] }).__gppQueue).toBeUndefined();
});
});
// ── ping command ──────────────────────────────────────────────────
describe('ping command', () => {
it('returns CMP metadata', async () => {
installGppApi(42, ['usnat', 'usca']);
const { data, success } = await callGpp('ping');
expect(success).toBe(true);
const ping = data as GppPingReturn;
expect(ping.gppVersion).toBe('1.1');
expect(ping.cmpStatus).toBe('loaded');
expect(ping.cmpDisplayStatus).toBe('hidden');
expect(ping.signalStatus).toBe('not ready');
expect(ping.supportedAPIs).toEqual(['usnat', 'usca']);
expect(ping.cmpId).toBe(42);
expect(ping.gppString).toBe('');
expect(ping.applicableSections).toEqual([]);
});
it('reflects updated display status', async () => {
installGppApi(1);
setGppDisplayStatus('visible');
const { data } = await callGpp('ping');
expect((data as GppPingReturn).cmpDisplayStatus).toBe('visible');
});
it('reflects updated signal status', async () => {
installGppApi(1);
setGppSignalStatus('ready');
const { data } = await callGpp('ping');
expect((data as GppPingReturn).signalStatus).toBe('ready');
});
it('includes GPP string after consent update', async () => {
installGppApi(1, ['usnat']);
const gpp = buildTestGpp();
const encoded = updateGppConsent(gpp);
expect(encoded).toBeTruthy();
const { data } = await callGpp('ping');
expect((data as GppPingReturn).gppString).toBe(encoded);
expect((data as GppPingReturn).applicableSections).toEqual([US_NATIONAL_ID]);
});
});
// ── getGPPData command ────────────────────────────────────────────
describe('getGPPData command', () => {
it('returns empty data before consent', async () => {
installGppApi(1);
const { data, success } = await callGpp('getGPPData');
expect(success).toBe(true);
const gppData = data as GppData;
expect(gppData.gppString).toBe('');
expect(gppData.applicableSections).toEqual([]);
expect(gppData.parsedSections).toEqual({});
});
it('returns populated data after consent update', async () => {
installGppApi(1, ['usnat']);
const gpp = buildTestGpp();
updateGppConsent(gpp);
const { data } = await callGpp('getGPPData');
const gppData = data as GppData;
expect(gppData.gppString).toBeTruthy();
expect(gppData.applicableSections).toEqual([US_NATIONAL_ID]);
expect(gppData.parsedSections[US_NATIONAL_ID]).toBeDefined();
});
});
// ── getSection command ────────────────────────────────────────────
describe('getSection command', () => {
it('returns null for missing prefix', async () => {
installGppApi(1);
const { data, success } = await callGpp('getSection', 'usnat');
expect(success).toBe(false);
expect(data).toBeNull();
});
it('returns null when no parameter given', async () => {
installGppApi(1);
const { data, success } = await callGpp('getSection');
expect(success).toBe(false);
expect(data).toBeNull();
});
it('returns section data by API prefix', async () => {
installGppApi(1, ['usnat']);
const gpp = buildTestGpp();
updateGppConsent(gpp);
const { data, success } = await callGpp('getSection', 'usnat');
expect(success).toBe(true);
expect(data).toBeDefined();
expect((data as Record<string, unknown>).Version).toBeDefined();
});
it('returns null for unregistered prefix', async () => {
installGppApi(1);
updateGppConsent(buildTestGpp());
const { data, success } = await callGpp('getSection', 'nonexistent');
expect(success).toBe(false);
expect(data).toBeNull();
});
});
// ── hasSection command ────────────────────────────────────────────
describe('hasSection command', () => {
it('returns false when section not present', async () => {
installGppApi(1);
const { data, success } = await callGpp('hasSection', 'usnat');
expect(success).toBe(true);
expect(data).toBe(false);
});
it('returns true when section present', async () => {
installGppApi(1, ['usnat']);
updateGppConsent(buildTestGpp());
const { data, success } = await callGpp('hasSection', 'usnat');
expect(success).toBe(true);
expect(data).toBe(true);
});
it('returns false for empty parameter', async () => {
installGppApi(1);
const { data, success } = await callGpp('hasSection');
expect(success).toBe(true);
expect(data).toBe(false);
});
});
// ── addEventListener / removeEventListener ────────────────────────
describe('addEventListener', () => {
it('registers a listener and fires immediately', async () => {
installGppApi(1, ['usnat']);
const { data, success } = await callGpp('addEventListener');
expect(success).toBe(true);
const event = data as GppEventData;
expect(event.eventName).toBe('listenerRegistered');
expect(event.listenerId).toBe(1);
expect(event.pingData.cmpStatus).toBe('loaded');
});
it('assigns unique listener IDs', async () => {
installGppApi(1);
const { data: d1 } = await callGpp('addEventListener');
const { data: d2 } = await callGpp('addEventListener');
expect((d1 as GppEventData).listenerId).toBe(1);
expect((d2 as GppEventData).listenerId).toBe(2);
});
it('notifies listeners on consent update', () => {
installGppApi(1, ['usnat']);
const events: GppEventData[] = [];
window.__gpp!('addEventListener', (data: unknown) => {
events.push(data as GppEventData);
});
// First event is listenerRegistered
expect(events).toHaveLength(1);
expect(events[0].eventName).toBe('listenerRegistered');
// Update consent — should fire signalStatus event
updateGppConsent(buildTestGpp());
expect(events).toHaveLength(2);
expect(events[1].eventName).toBe('signalStatus');
expect(events[1].data).toBeDefined();
expect((events[1].data as GppData).gppString).toBeTruthy();
});
it('swallows errors thrown by listeners', () => {
installGppApi(1);
const throwingCallback: GppApiCallback = () => {
throw new Error('Listener error');
};
// Should not throw
expect(() => {
window.__gpp!('addEventListener', throwingCallback);
}).not.toThrow();
// Should not throw during consent update either
expect(() => {
updateGppConsent(buildTestGpp());
}).not.toThrow();
});
});
describe('removeEventListener', () => {
it('removes an existing listener', async () => {
installGppApi(1);
// Add listener
const { data } = await callGpp('addEventListener');
const listenerId = (data as GppEventData).listenerId;
// Remove it
const { data: removed, success } = await callGpp('removeEventListener', listenerId);
expect(success).toBe(true);
expect(removed).toBe(true);
});
it('returns false for non-existent listener', async () => {
installGppApi(1);
const { data, success } = await callGpp('removeEventListener', 999);
expect(success).toBe(false);
expect(data).toBe(false);
});
it('stops notifications after removal', () => {
installGppApi(1, ['usnat']);
const events: GppEventData[] = [];
let listenerId: number;
window.__gpp!('addEventListener', (data: unknown) => {
const event = data as GppEventData;
events.push(event);
listenerId = event.listenerId;
});
// Remove the listener
window.__gpp!('removeEventListener', (_d: unknown, _s: boolean) => {}, listenerId!);
// Update consent — should NOT fire to removed listener
const eventsBefore = events.length;
updateGppConsent(buildTestGpp());
expect(events.length).toBe(eventsBefore);
});
});
// ── Unknown commands ──────────────────────────────────────────────
describe('unknown commands', () => {
it('returns false for unknown commands', async () => {
installGppApi(1);
const { data, success } = await callGpp('unknownCommand');
expect(success).toBe(false);
expect(data).toBe(false);
});
});
// ── Uninitialised state ───────────────────────────────────────────
describe('uninitialised state', () => {
it('returns false when API not installed', () => {
// Manually set __gpp to the handler without initialising state
// by calling removeGppApi first to clear state, then manually assigning
removeGppApi();
// Directly import and call the handler — simulate calling before install
let callbackData: unknown;
let callbackSuccess: boolean | undefined;
// We can't call __gpp because it's removed, but we can test via getGppString
expect(getGppString()).toBe('');
expect(isGppApiInstalled()).toBe(false);
});
});
// ── updateGppConsent ──────────────────────────────────────────────
describe('updateGppConsent', () => {
it('returns the encoded GPP string', () => {
installGppApi(1, ['usnat']);
const gpp = buildTestGpp();
const result = updateGppConsent(gpp);
expect(result).toBeTruthy();
expect(typeof result).toBe('string');
// GPP strings start with 'D' (header prefix)
expect(result.startsWith('D')).toBe(true);
});
it('updates the gppString accessible via getGppString', () => {
installGppApi(1);
expect(getGppString()).toBe('');
updateGppConsent(buildTestGpp());
expect(getGppString()).toBeTruthy();
});
it('sets signalStatus to ready', async () => {
installGppApi(1);
updateGppConsent(buildTestGpp());
const { data } = await callGpp('ping');
expect((data as GppPingReturn).signalStatus).toBe('ready');
});
it('works with multiple sections', () => {
installGppApi(1, ['usnat', 'usca']);
const gpp = buildTestGpp([US_NATIONAL_ID, US_CALIFORNIA_ID]);
const result = updateGppConsent(gpp);
expect(result).toBeTruthy();
// Should contain ~ separator for sections
expect(result).toContain('~');
});
});
// ── Queue processing ──────────────────────────────────────────────
describe('queue processing', () => {
it('processes queued calls on install', () => {
// Set up a queue before installing
const results: Array<{ data: unknown; success: boolean }> = [];
const cb = (data: unknown, success: boolean) => {
results.push({ data, success });
};
(window as { __gppQueue?: unknown[] }).__gppQueue = [
['ping', cb],
];
installGppApi(1, ['usnat']);
// The queued ping should have been processed
expect(results).toHaveLength(1);
expect(results[0].success).toBe(true);
expect((results[0].data as GppPingReturn).cmpStatus).toBe('loaded');
});
it('clears the queue after processing', () => {
(window as { __gppQueue?: unknown[] }).__gppQueue = [
['ping', vi.fn()],
];
installGppApi(1);
expect((window as { __gppQueue?: unknown[] }).__gppQueue).toEqual([]);
});
});
// ── Coexistence with __tcfapi ─────────────────────────────────────
describe('coexistence', () => {
it('does not interfere with other window globals', () => {
// Set a mock __tcfapi
const mockTcf = vi.fn();
(window as { __tcfapi?: unknown }).__tcfapi = mockTcf;
installGppApi(1);
// __tcfapi should still be intact
expect((window as { __tcfapi?: unknown }).__tcfapi).toBe(mockTcf);
removeGppApi();
// __tcfapi should still be intact after removal
expect((window as { __tcfapi?: unknown }).__tcfapi).toBe(mockTcf);
// Clean up
delete (window as { __tcfapi?: unknown }).__tcfapi;
});
});
// ── setGppDisplayStatus / setGppSignalStatus ──────────────────────
describe('status setters', () => {
it('setGppDisplayStatus updates display status', async () => {
installGppApi(1);
setGppDisplayStatus('visible');
const { data } = await callGpp('ping');
expect((data as GppPingReturn).cmpDisplayStatus).toBe('visible');
setGppDisplayStatus('disabled');
const { data: d2 } = await callGpp('ping');
expect((d2 as GppPingReturn).cmpDisplayStatus).toBe('disabled');
});
it('setGppSignalStatus updates signal status', async () => {
installGppApi(1);
setGppSignalStatus('ready');
const { data } = await callGpp('ping');
expect((data as GppPingReturn).signalStatus).toBe('ready');
});
it('no-ops when API not installed', () => {
// Should not throw
expect(() => setGppDisplayStatus('visible')).not.toThrow();
expect(() => setGppSignalStatus('ready')).not.toThrow();
});
});
});

View File

@@ -0,0 +1,926 @@
/**
* Tests for the IAB GPP string encoder/decoder.
*/
import { describe, it, expect } from 'vitest';
import { BitWriter, BitReader, bytesToBase64url } from '../tcf';
import {
fibonacciEncode,
fibonacciDecode,
encodeGppHeader,
decodeGppHeader,
encodeSectionCore,
decodeSectionCore,
encodeGpcSubsection,
decodeGpcSubsection,
encodeSection,
decodeSection,
encodeGppString,
decodeGppString,
createDefaultSectionData,
getSectionByPrefix,
registerSection,
US_NATIONAL,
US_CALIFORNIA,
US_VIRGINIA,
US_COLORADO,
US_CONNECTICUT,
US_FLORIDA,
SECTION_REGISTRY,
GppFieldValue,
type SectionDef,
type SectionData,
type GppHeader,
type GppString,
} from '../gpp';
// ── Fibonacci coding ─────────────────────────────────────────────────
describe('Fibonacci coding', () => {
it('encodes and decodes 1', () => {
const writer = new BitWriter();
fibonacciEncode(writer, 1);
const bytes = writer.toBytes();
const reader = new BitReader(bytes);
expect(fibonacciDecode(reader)).toBe(1);
});
it('encodes and decodes 2', () => {
const writer = new BitWriter();
fibonacciEncode(writer, 2);
const bytes = writer.toBytes();
const reader = new BitReader(bytes);
expect(fibonacciDecode(reader)).toBe(2);
});
it('encodes and decodes small integers (120)', () => {
for (let n = 1; n <= 20; n++) {
const writer = new BitWriter();
fibonacciEncode(writer, n);
const bytes = writer.toBytes();
const reader = new BitReader(bytes);
expect(fibonacciDecode(reader)).toBe(n);
}
});
it('encodes and decodes larger integers', () => {
const values = [21, 34, 55, 100, 233, 500, 987, 1000];
for (const n of values) {
const writer = new BitWriter();
fibonacciEncode(writer, n);
const bytes = writer.toBytes();
const reader = new BitReader(bytes);
expect(fibonacciDecode(reader)).toBe(n);
}
});
it('encodes multiple integers sequentially', () => {
const values = [7, 3, 11, 1, 8];
const writer = new BitWriter();
for (const v of values) {
fibonacciEncode(writer, v);
}
const bytes = writer.toBytes();
const reader = new BitReader(bytes);
for (const v of values) {
expect(fibonacciDecode(reader)).toBe(v);
}
});
it('throws on zero or negative values', () => {
const writer = new BitWriter();
expect(() => fibonacciEncode(writer, 0)).toThrow('positive integer');
expect(() => fibonacciEncode(writer, -1)).toThrow('positive integer');
});
it('produces correct bit pattern for value 1 (11)', () => {
const writer = new BitWriter();
fibonacciEncode(writer, 1);
// Value 1: position 0 set (F(2)=1), then terminator → bits: 1,1
const bytes = writer.toBytes();
// First byte: 11xxxxxx → 0b11000000 = 192
expect(bytes[0] & 0xc0).toBe(0xc0);
});
it('produces correct bit pattern for value 2 (011)', () => {
const writer = new BitWriter();
fibonacciEncode(writer, 2);
// Value 2: position 1 set (F(3)=2), then terminator → bits: 0,1,1
const bytes = writer.toBytes();
// First byte: 011xxxxx → 0b01100000 = 96
expect(bytes[0] & 0xe0).toBe(0x60);
});
it('produces correct bit pattern for value 4 (1011)', () => {
const writer = new BitWriter();
fibonacciEncode(writer, 4);
// Value 4 = F(2)+F(4) = 1+3: positions 0,2 set, then terminator → bits: 1,0,1,1
const bytes = writer.toBytes();
// First byte: 1011xxxx → 0b10110000 = 176
expect(bytes[0] & 0xf0).toBe(0xb0);
});
});
// ── GPP Header ───────────────────────────────────────────────────────
describe('GPP header', () => {
it('encodes and decodes an empty header', () => {
const header: GppHeader = {
version: 1,
sectionIds: [],
applicableSections: [],
};
const encoded = encodeGppHeader(header);
const decoded = decodeGppHeader(encoded);
expect(decoded.version).toBe(1);
expect(decoded.sectionIds).toEqual([]);
expect(decoded.applicableSections).toEqual([]);
});
it('encodes and decodes a header with a single section', () => {
const header: GppHeader = {
version: 1,
sectionIds: [7],
applicableSections: [7],
};
const encoded = encodeGppHeader(header);
const decoded = decodeGppHeader(encoded);
expect(decoded.version).toBe(1);
expect(decoded.sectionIds).toEqual([7]);
expect(decoded.applicableSections).toEqual([7]);
});
it('encodes and decodes a header with multiple sections', () => {
const header: GppHeader = {
version: 1,
sectionIds: [7, 8, 10],
applicableSections: [7, 8],
};
const encoded = encodeGppHeader(header);
const decoded = decodeGppHeader(encoded);
expect(decoded.version).toBe(1);
expect(decoded.sectionIds).toEqual([7, 8, 10]);
expect(decoded.applicableSections).toEqual([7, 8]);
});
it('encodes consecutive section IDs as ranges', () => {
const header: GppHeader = {
version: 1,
sectionIds: [7, 8, 9, 10, 11],
applicableSections: [7, 8, 9, 10, 11],
};
const encoded = encodeGppHeader(header);
const decoded = decodeGppHeader(encoded);
expect(decoded.sectionIds).toEqual([7, 8, 9, 10, 11]);
expect(decoded.applicableSections).toEqual([7, 8, 9, 10, 11]);
});
it('handles mixed consecutive and non-consecutive IDs', () => {
const header: GppHeader = {
version: 1,
sectionIds: [7, 8, 9, 11, 14],
applicableSections: [7],
};
const encoded = encodeGppHeader(header);
const decoded = decodeGppHeader(encoded);
expect(decoded.sectionIds).toEqual([7, 8, 9, 11, 14]);
expect(decoded.applicableSections).toEqual([7]);
});
it('throws on invalid header type', () => {
// Craft a header with type = 0 instead of 3
const writer = new BitWriter();
writer.writeInt(0, 6); // Wrong type
writer.writeInt(1, 6);
writer.writeInt(0, 6);
writer.writeInt(0, 6);
const encoded = bytesToBase64url(writer.toBytes());
expect(() => decodeGppHeader(encoded)).toThrow('Invalid GPP header type');
});
it('round-trips: encode → decode → re-encode produces identical output', () => {
const header: GppHeader = {
version: 1,
sectionIds: [7, 8, 11, 14],
applicableSections: [7, 8],
};
const encoded1 = encodeGppHeader(header);
const decoded = decodeGppHeader(encoded1);
const encoded2 = encodeGppHeader(decoded);
expect(encoded2).toBe(encoded1);
});
});
// ── Section encoding/decoding ────────────────────────────────────────
describe('US National section (Section 7)', () => {
it('encodes and decodes default (all-zero) data', () => {
const data = createDefaultSectionData(US_NATIONAL);
const encoded = encodeSectionCore(US_NATIONAL, data);
const decoded = decodeSectionCore(US_NATIONAL, encoded);
expect(decoded.Version).toBe(1);
expect(decoded.SharingNotice).toBe(0);
expect(decoded.SaleOptOut).toBe(0);
expect(decoded.SensitiveDataProcessing).toEqual(new Array(12).fill(0));
expect(decoded.KnownChildSensitiveDataConsents).toEqual([0, 0]);
});
it('encodes and decodes populated data', () => {
const data: SectionData = {
Version: 1,
SharingNotice: 1,
SaleOptOutNotice: 1,
SharingOptOutNotice: 1,
TargetedAdvertisingOptOutNotice: 1,
SensitiveDataProcessingOptOutNotice: 1,
SensitiveDataLimitUseNotice: 1,
SaleOptOut: 2,
SharingOptOut: 1,
TargetedAdvertisingOptOut: 2,
SensitiveDataProcessing: [1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0],
KnownChildSensitiveDataConsents: [1, 2],
PersonalDataConsents: 0,
MspaCoveredTransaction: 1,
MspaOptOutOptionMode: 1,
MspaServiceProviderMode: 0,
};
const encoded = encodeSectionCore(US_NATIONAL, data);
const decoded = decodeSectionCore(US_NATIONAL, encoded);
expect(decoded.Version).toBe(1);
expect(decoded.SharingNotice).toBe(1);
expect(decoded.SaleOptOut).toBe(2);
expect(decoded.SharingOptOut).toBe(1);
expect(decoded.TargetedAdvertisingOptOut).toBe(2);
expect(decoded.SensitiveDataProcessing).toEqual([1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0]);
expect(decoded.KnownChildSensitiveDataConsents).toEqual([1, 2]);
expect(decoded.MspaCoveredTransaction).toBe(1);
});
it('round-trips core data', () => {
const data: SectionData = {
Version: 1,
SharingNotice: 2,
SaleOptOutNotice: 1,
SharingOptOutNotice: 2,
TargetedAdvertisingOptOutNotice: 1,
SensitiveDataProcessingOptOutNotice: 2,
SensitiveDataLimitUseNotice: 1,
SaleOptOut: 1,
SharingOptOut: 2,
TargetedAdvertisingOptOut: 1,
SensitiveDataProcessing: [2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1],
KnownChildSensitiveDataConsents: [2, 1],
PersonalDataConsents: 2,
MspaCoveredTransaction: 2,
MspaOptOutOptionMode: 2,
MspaServiceProviderMode: 2,
};
const encoded1 = encodeSectionCore(US_NATIONAL, data);
const decoded = decodeSectionCore(US_NATIONAL, encoded1);
const encoded2 = encodeSectionCore(US_NATIONAL, decoded);
expect(encoded2).toBe(encoded1);
});
});
describe('US California section (Section 8)', () => {
it('encodes and decodes default data', () => {
const data = createDefaultSectionData(US_CALIFORNIA);
const encoded = encodeSectionCore(US_CALIFORNIA, data);
const decoded = decodeSectionCore(US_CALIFORNIA, encoded);
expect(decoded.Version).toBe(1);
expect(decoded.SaleOptOutNotice).toBe(0);
expect(decoded.SensitiveDataProcessing).toEqual(new Array(9).fill(0));
expect(decoded.KnownChildSensitiveDataConsents).toEqual([0, 0]);
});
it('encodes and decodes populated data', () => {
const data: SectionData = {
Version: 1,
SaleOptOutNotice: 1,
SharingOptOutNotice: 1,
SensitiveDataLimitUseNotice: 2,
SaleOptOut: 1,
SharingOptOut: 1,
SensitiveDataProcessing: [1, 1, 2, 2, 0, 0, 1, 1, 2],
KnownChildSensitiveDataConsents: [1, 0],
PersonalDataConsents: 1,
MspaCoveredTransaction: 1,
MspaOptOutOptionMode: 0,
MspaServiceProviderMode: 2,
};
const encoded = encodeSectionCore(US_CALIFORNIA, data);
const decoded = decodeSectionCore(US_CALIFORNIA, encoded);
expect(decoded.SaleOptOut).toBe(1);
expect(decoded.SharingOptOut).toBe(1);
expect(decoded.SensitiveDataProcessing).toEqual([1, 1, 2, 2, 0, 0, 1, 1, 2]);
expect(decoded.MspaServiceProviderMode).toBe(2);
});
it('round-trips core data', () => {
const data: SectionData = {
Version: 1,
SaleOptOutNotice: 2,
SharingOptOutNotice: 1,
SensitiveDataLimitUseNotice: 1,
SaleOptOut: 2,
SharingOptOut: 2,
SensitiveDataProcessing: [2, 2, 1, 1, 0, 0, 2, 2, 1],
KnownChildSensitiveDataConsents: [2, 2],
PersonalDataConsents: 2,
MspaCoveredTransaction: 1,
MspaOptOutOptionMode: 1,
MspaServiceProviderMode: 1,
};
const e1 = encodeSectionCore(US_CALIFORNIA, data);
const d = decodeSectionCore(US_CALIFORNIA, e1);
const e2 = encodeSectionCore(US_CALIFORNIA, d);
expect(e2).toBe(e1);
});
});
describe('US Virginia section (Section 9)', () => {
it('encodes and decodes default data', () => {
const data = createDefaultSectionData(US_VIRGINIA);
const encoded = encodeSectionCore(US_VIRGINIA, data);
const decoded = decodeSectionCore(US_VIRGINIA, encoded);
expect(decoded.Version).toBe(1);
expect(decoded.SensitiveDataProcessing).toEqual(new Array(8).fill(0));
expect(decoded.KnownChildSensitiveDataConsents).toBe(0);
});
it('round-trips core data', () => {
const data: SectionData = {
Version: 1,
SharingNotice: 1,
SaleOptOutNotice: 2,
TargetedAdvertisingOptOutNotice: 1,
SaleOptOut: 2,
TargetedAdvertisingOptOut: 1,
SensitiveDataProcessing: [1, 2, 1, 2, 1, 2, 1, 2],
KnownChildSensitiveDataConsents: 1,
MspaCoveredTransaction: 2,
MspaOptOutOptionMode: 1,
MspaServiceProviderMode: 2,
};
const e1 = encodeSectionCore(US_VIRGINIA, data);
const d = decodeSectionCore(US_VIRGINIA, e1);
const e2 = encodeSectionCore(US_VIRGINIA, d);
expect(e2).toBe(e1);
});
it('does not support GPC sub-section', () => {
expect(US_VIRGINIA.hasGpcSubsection).toBe(false);
});
});
describe('US Colorado section (Section 10)', () => {
it('encodes and decodes default data', () => {
const data = createDefaultSectionData(US_COLORADO);
const encoded = encodeSectionCore(US_COLORADO, data);
const decoded = decodeSectionCore(US_COLORADO, encoded);
expect(decoded.Version).toBe(1);
expect(decoded.SensitiveDataProcessing).toEqual(new Array(7).fill(0));
});
it('round-trips core data', () => {
const data: SectionData = {
Version: 1,
SharingNotice: 2,
SaleOptOutNotice: 1,
TargetedAdvertisingOptOutNotice: 2,
SaleOptOut: 1,
TargetedAdvertisingOptOut: 2,
SensitiveDataProcessing: [2, 1, 0, 2, 1, 0, 2],
KnownChildSensitiveDataConsents: 1,
MspaCoveredTransaction: 1,
MspaOptOutOptionMode: 2,
MspaServiceProviderMode: 0,
};
const e1 = encodeSectionCore(US_COLORADO, data);
const d = decodeSectionCore(US_COLORADO, e1);
const e2 = encodeSectionCore(US_COLORADO, d);
expect(e2).toBe(e1);
});
it('supports GPC sub-section', () => {
expect(US_COLORADO.hasGpcSubsection).toBe(true);
});
});
describe('US Connecticut section (Section 11)', () => {
it('encodes and decodes default data', () => {
const data = createDefaultSectionData(US_CONNECTICUT);
const encoded = encodeSectionCore(US_CONNECTICUT, data);
const decoded = decodeSectionCore(US_CONNECTICUT, encoded);
expect(decoded.Version).toBe(1);
expect(decoded.SensitiveDataProcessing).toEqual(new Array(8).fill(0));
expect(decoded.KnownChildSensitiveDataConsents).toEqual([0, 0, 0]);
});
it('round-trips core data', () => {
const data: SectionData = {
Version: 1,
SharingNotice: 1,
SaleOptOutNotice: 1,
TargetedAdvertisingOptOutNotice: 1,
SaleOptOut: 1,
TargetedAdvertisingOptOut: 1,
SensitiveDataProcessing: [1, 1, 1, 1, 1, 1, 1, 1],
KnownChildSensitiveDataConsents: [1, 2, 1],
MspaCoveredTransaction: 2,
MspaOptOutOptionMode: 0,
MspaServiceProviderMode: 0,
};
const e1 = encodeSectionCore(US_CONNECTICUT, data);
const d = decodeSectionCore(US_CONNECTICUT, e1);
const e2 = encodeSectionCore(US_CONNECTICUT, d);
expect(e2).toBe(e1);
});
});
describe('US Florida section (Section 14)', () => {
it('encodes and decodes default data', () => {
const data = createDefaultSectionData(US_FLORIDA);
const encoded = encodeSectionCore(US_FLORIDA, data);
const decoded = decodeSectionCore(US_FLORIDA, encoded);
expect(decoded.Version).toBe(1);
expect(decoded.SensitiveDataProcessing).toEqual(new Array(8).fill(0));
expect(decoded.KnownChildSensitiveDataConsents).toEqual([0, 0, 0]);
expect(decoded.PersonalDataConsents).toBe(0);
});
it('round-trips core data', () => {
const data: SectionData = {
Version: 1,
SharingNotice: 2,
SaleOptOutNotice: 2,
TargetedAdvertisingOptOutNotice: 2,
SaleOptOut: 2,
TargetedAdvertisingOptOut: 2,
SensitiveDataProcessing: [2, 2, 2, 2, 2, 2, 2, 2],
KnownChildSensitiveDataConsents: [2, 2, 2],
PersonalDataConsents: 2,
MspaCoveredTransaction: 2,
MspaOptOutOptionMode: 2,
MspaServiceProviderMode: 2,
};
const e1 = encodeSectionCore(US_FLORIDA, data);
const d = decodeSectionCore(US_FLORIDA, e1);
const e2 = encodeSectionCore(US_FLORIDA, d);
expect(e2).toBe(e1);
});
it('supports GPC sub-section', () => {
expect(US_FLORIDA.hasGpcSubsection).toBe(true);
});
});
// ── GPC sub-section ──────────────────────────────────────────────────
describe('GPC sub-section', () => {
it('encodes and decodes gpc=true', () => {
const encoded = encodeGpcSubsection({ gpc: true });
const decoded = decodeGpcSubsection(encoded);
expect(decoded.gpc).toBe(true);
});
it('encodes and decodes gpc=false', () => {
const encoded = encodeGpcSubsection({ gpc: false });
const decoded = decodeGpcSubsection(encoded);
expect(decoded.gpc).toBe(false);
});
it('round-trips: encode → decode → re-encode is identical', () => {
const gpc = { gpc: true };
const e1 = encodeGpcSubsection(gpc);
const d = decodeGpcSubsection(e1);
const e2 = encodeGpcSubsection(d);
expect(e2).toBe(e1);
});
});
// ── Full section with GPC sub-section ────────────────────────────────
describe('encodeSection / decodeSection (core + GPC)', () => {
it('encodes section without GPC when not provided', () => {
const data = createDefaultSectionData(US_NATIONAL);
const encoded = encodeSection(US_NATIONAL, data);
expect(encoded).not.toContain('.');
});
it('encodes section with GPC sub-section', () => {
const data = createDefaultSectionData(US_NATIONAL);
const encoded = encodeSection(US_NATIONAL, data, { gpc: true });
expect(encoded).toContain('.');
});
it('decodes section with GPC sub-section', () => {
const data: SectionData = {
...createDefaultSectionData(US_NATIONAL),
SaleOptOut: 1,
SharingOptOut: 1,
};
const encoded = encodeSection(US_NATIONAL, data, { gpc: true });
const { data: decoded, gpcSubsection } = decodeSection(US_NATIONAL, encoded);
expect(decoded.SaleOptOut).toBe(1);
expect(decoded.SharingOptOut).toBe(1);
expect(gpcSubsection).toBeDefined();
expect(gpcSubsection!.gpc).toBe(true);
});
it('ignores GPC sub-section for sections that do not support it', () => {
const data = createDefaultSectionData(US_VIRGINIA);
const encoded = encodeSection(US_VIRGINIA, data, { gpc: true });
// Virginia doesn't support GPC — should not append sub-section
expect(encoded).not.toContain('.');
const { gpcSubsection } = decodeSection(US_VIRGINIA, encoded);
expect(gpcSubsection).toBeUndefined();
});
it('round-trips section with GPC', () => {
const data: SectionData = {
...createDefaultSectionData(US_CALIFORNIA),
SaleOptOut: 2,
SharingOptOut: 1,
};
const gpc = { gpc: false };
const e1 = encodeSection(US_CALIFORNIA, data, gpc);
const { data: d, gpcSubsection } = decodeSection(US_CALIFORNIA, e1);
const e2 = encodeSection(US_CALIFORNIA, d, gpcSubsection);
expect(e2).toBe(e1);
});
});
// ── Full GPP string ──────────────────────────────────────────────────
describe('full GPP string encoding/decoding', () => {
it('encodes and decodes a single US National section', () => {
const gpp: GppString = {
header: {
version: 1,
sectionIds: [7],
applicableSections: [7],
},
sections: new Map([
[7, { data: createDefaultSectionData(US_NATIONAL), gpcSubsection: { gpc: false } }],
]),
};
const encoded = encodeGppString(gpp);
expect(encoded).toContain('~');
const decoded = decodeGppString(encoded);
expect(decoded.header.sectionIds).toEqual([7]);
expect(decoded.sections.has(7)).toBe(true);
expect(decoded.sections.get(7)!.data.Version).toBe(1);
expect(decoded.sections.get(7)!.gpcSubsection?.gpc).toBe(false);
});
it('encodes and decodes multiple sections', () => {
const gpp: GppString = {
header: {
version: 1,
sectionIds: [7, 8, 10],
applicableSections: [8],
},
sections: new Map([
[7, {
data: {
...createDefaultSectionData(US_NATIONAL),
SaleOptOut: 1,
SharingOptOut: 1,
},
gpcSubsection: { gpc: true },
}],
[8, {
data: {
...createDefaultSectionData(US_CALIFORNIA),
SaleOptOut: 1,
},
gpcSubsection: { gpc: true },
}],
[10, {
data: {
...createDefaultSectionData(US_COLORADO),
TargetedAdvertisingOptOut: 2,
},
gpcSubsection: { gpc: false },
}],
]),
};
const encoded = encodeGppString(gpp);
const parts = encoded.split('~');
expect(parts.length).toBe(4); // header + 3 sections
const decoded = decodeGppString(encoded);
expect(decoded.header.sectionIds).toEqual([7, 8, 10]);
expect(decoded.header.applicableSections).toEqual([8]);
expect(decoded.sections.get(7)!.data.SaleOptOut).toBe(1);
expect(decoded.sections.get(7)!.gpcSubsection?.gpc).toBe(true);
expect(decoded.sections.get(8)!.data.SaleOptOut).toBe(1);
expect(decoded.sections.get(10)!.data.TargetedAdvertisingOptOut).toBe(2);
});
it('round-trips a complex GPP string', () => {
const gpp: GppString = {
header: {
version: 1,
sectionIds: [7, 8, 9, 10, 11, 14],
applicableSections: [7, 8],
},
sections: new Map([
[7, {
data: {
...createDefaultSectionData(US_NATIONAL),
SharingNotice: 1,
SaleOptOutNotice: 1,
SaleOptOut: 1,
SharingOptOut: 1,
TargetedAdvertisingOptOut: 1,
SensitiveDataProcessing: [1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2],
KnownChildSensitiveDataConsents: [0, 0],
MspaCoveredTransaction: 1,
},
gpcSubsection: { gpc: true },
}],
[8, {
data: {
...createDefaultSectionData(US_CALIFORNIA),
SaleOptOut: 1,
SharingOptOut: 1,
SensitiveDataProcessing: [2, 2, 2, 2, 2, 2, 2, 2, 2],
},
gpcSubsection: { gpc: true },
}],
[9, {
data: {
...createDefaultSectionData(US_VIRGINIA),
SaleOptOut: 2,
},
}],
[10, {
data: createDefaultSectionData(US_COLORADO),
gpcSubsection: { gpc: false },
}],
[11, {
data: createDefaultSectionData(US_CONNECTICUT),
gpcSubsection: { gpc: false },
}],
[14, {
data: createDefaultSectionData(US_FLORIDA),
gpcSubsection: { gpc: true },
}],
]),
};
const encoded1 = encodeGppString(gpp);
const decoded = decodeGppString(encoded1);
const encoded2 = encodeGppString(decoded);
expect(encoded2).toBe(encoded1);
});
it('throws when section data is missing', () => {
const gpp: GppString = {
header: {
version: 1,
sectionIds: [7, 8],
applicableSections: [],
},
sections: new Map([
[7, { data: createDefaultSectionData(US_NATIONAL) }],
// Section 8 intentionally missing
]),
};
expect(() => encodeGppString(gpp)).toThrow('No data or definition for GPP section 8');
});
it('throws when section payload is missing during decode', () => {
// Manually craft a string with a header claiming 2 sections but only 1 payload
const header: GppHeader = {
version: 1,
sectionIds: [7, 8],
applicableSections: [],
};
const headerStr = encodeGppHeader(header);
const sectionStr = encodeSectionCore(US_NATIONAL, createDefaultSectionData(US_NATIONAL));
const gppStr = `${headerStr}~${sectionStr}`;
// Missing section 8 payload
expect(() => decodeGppString(gppStr)).toThrow('Missing payload for GPP section 8');
});
});
// ── Convenience helpers ──────────────────────────────────────────────
describe('createDefaultSectionData', () => {
it('creates default data with correct Version for each section', () => {
for (const def of SECTION_REGISTRY.values()) {
const data = createDefaultSectionData(def);
expect(data.Version).toBe(def.version);
}
});
it('creates correct array sizes for US National', () => {
const data = createDefaultSectionData(US_NATIONAL);
expect((data.SensitiveDataProcessing as number[]).length).toBe(12);
expect((data.KnownChildSensitiveDataConsents as number[]).length).toBe(2);
});
it('creates correct array sizes for US California', () => {
const data = createDefaultSectionData(US_CALIFORNIA);
expect((data.SensitiveDataProcessing as number[]).length).toBe(9);
expect((data.KnownChildSensitiveDataConsents as number[]).length).toBe(2);
});
it('creates correct array sizes for US Connecticut', () => {
const data = createDefaultSectionData(US_CONNECTICUT);
expect((data.SensitiveDataProcessing as number[]).length).toBe(8);
expect((data.KnownChildSensitiveDataConsents as number[]).length).toBe(3);
});
it('uses scalar for single-count fields', () => {
const data = createDefaultSectionData(US_VIRGINIA);
expect(typeof data.KnownChildSensitiveDataConsents).toBe('number');
});
});
describe('getSectionByPrefix', () => {
it('finds US National by prefix', () => {
expect(getSectionByPrefix('usnat')).toBe(US_NATIONAL);
});
it('finds US California by prefix', () => {
expect(getSectionByPrefix('usca')).toBe(US_CALIFORNIA);
});
it('finds US Virginia by prefix', () => {
expect(getSectionByPrefix('usva')).toBe(US_VIRGINIA);
});
it('finds US Colorado by prefix', () => {
expect(getSectionByPrefix('usco')).toBe(US_COLORADO);
});
it('finds US Connecticut by prefix', () => {
expect(getSectionByPrefix('usct')).toBe(US_CONNECTICUT);
});
it('finds US Florida by prefix', () => {
expect(getSectionByPrefix('usfl')).toBe(US_FLORIDA);
});
it('returns undefined for unknown prefix', () => {
expect(getSectionByPrefix('unknown')).toBeUndefined();
});
});
describe('registerSection', () => {
it('registers a custom section definition', () => {
const customDef: SectionDef = {
id: 99,
apiPrefix: 'custom',
version: 1,
coreFields: [
{ name: 'Version', bits: 6, count: 1 },
{ name: 'OptOut', bits: 2, count: 1 },
],
hasGpcSubsection: false,
};
registerSection(customDef);
expect(SECTION_REGISTRY.get(99)).toBe(customDef);
expect(getSectionByPrefix('custom')).toBe(customDef);
// Encode and decode using the custom section
const data: SectionData = { Version: 1, OptOut: 2 };
const encoded = encodeSectionCore(customDef, data);
const decoded = decodeSectionCore(customDef, encoded);
expect(decoded.Version).toBe(1);
expect(decoded.OptOut).toBe(2);
// Clean up
SECTION_REGISTRY.delete(99);
});
});
// ── Section registry completeness ────────────────────────────────────
describe('section registry', () => {
it('contains all required sections', () => {
expect(SECTION_REGISTRY.has(7)).toBe(true); // US National
expect(SECTION_REGISTRY.has(8)).toBe(true); // US California
expect(SECTION_REGISTRY.has(9)).toBe(true); // US Virginia
expect(SECTION_REGISTRY.has(10)).toBe(true); // US Colorado
expect(SECTION_REGISTRY.has(11)).toBe(true); // US Connecticut
expect(SECTION_REGISTRY.has(14)).toBe(true); // US Florida
});
it('each section has a unique apiPrefix', () => {
const prefixes = new Set<string>();
for (const def of SECTION_REGISTRY.values()) {
expect(prefixes.has(def.apiPrefix)).toBe(false);
prefixes.add(def.apiPrefix);
}
});
it('each section has a unique id', () => {
const ids = new Set<number>();
for (const def of SECTION_REGISTRY.values()) {
expect(ids.has(def.id)).toBe(false);
ids.add(def.id);
}
});
});
// ── GppFieldValue constants ──────────────────────────────────────────
describe('GppFieldValue constants', () => {
it('has correct values', () => {
expect(GppFieldValue.NOT_APPLICABLE).toBe(0);
expect(GppFieldValue.YES).toBe(1);
expect(GppFieldValue.NO).toBe(2);
});
});
// ── Edge cases ───────────────────────────────────────────────────────
describe('edge cases', () => {
it('handles all-maximum field values for US National', () => {
const data: SectionData = {
Version: 63, // max for 6 bits
SharingNotice: 3, // max for 2 bits
SaleOptOutNotice: 3,
SharingOptOutNotice: 3,
TargetedAdvertisingOptOutNotice: 3,
SensitiveDataProcessingOptOutNotice: 3,
SensitiveDataLimitUseNotice: 3,
SaleOptOut: 3,
SharingOptOut: 3,
TargetedAdvertisingOptOut: 3,
SensitiveDataProcessing: new Array(12).fill(3),
KnownChildSensitiveDataConsents: [3, 3],
PersonalDataConsents: 3,
MspaCoveredTransaction: 3,
MspaOptOutOptionMode: 3,
MspaServiceProviderMode: 3,
};
const encoded = encodeSectionCore(US_NATIONAL, data);
const decoded = decodeSectionCore(US_NATIONAL, encoded);
expect(decoded.Version).toBe(63);
expect(decoded.SharingNotice).toBe(3);
expect(decoded.SensitiveDataProcessing).toEqual(new Array(12).fill(3));
});
it('handles a GPP string with all 6 registered sections', () => {
const allSectionIds = [7, 8, 9, 10, 11, 14];
const sections = new Map<number, { data: SectionData; gpcSubsection?: { gpc: boolean } }>();
for (const id of allSectionIds) {
const def = SECTION_REGISTRY.get(id)!;
sections.set(id, {
data: createDefaultSectionData(def),
gpcSubsection: def.hasGpcSubsection ? { gpc: true } : undefined,
});
}
const gpp: GppString = {
header: {
version: 1,
sectionIds: allSectionIds,
applicableSections: allSectionIds,
},
sections,
};
const encoded = encodeGppString(gpp);
const decoded = decodeGppString(encoded);
expect(decoded.header.sectionIds).toEqual(allSectionIds);
expect(decoded.sections.size).toBe(6);
for (const id of allSectionIds) {
expect(decoded.sections.has(id)).toBe(true);
const def = SECTION_REGISTRY.get(id)!;
if (def.hasGpcSubsection) {
expect(decoded.sections.get(id)!.gpcSubsection?.gpc).toBe(true);
}
}
});
});

View File

@@ -0,0 +1,190 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
DEFAULT_TRANSLATIONS,
detectLocale,
fetchTranslations,
interpolate,
loadTranslations,
normaliseLocale,
} from '../i18n';
describe('i18n', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
describe('DEFAULT_TRANSLATIONS', () => {
it('should have all required keys', () => {
expect(DEFAULT_TRANSLATIONS.title).toBe('We use cookies');
expect(DEFAULT_TRANSLATIONS.acceptAll).toBe('Accept all');
expect(DEFAULT_TRANSLATIONS.rejectAll).toBe('Reject all');
expect(DEFAULT_TRANSLATIONS.managePreferences).toBe('Manage preferences');
expect(DEFAULT_TRANSLATIONS.savePreferences).toBe('Save preferences');
expect(DEFAULT_TRANSLATIONS.privacyPolicyLink).toBe('Privacy Policy');
expect(DEFAULT_TRANSLATIONS.closeLabel).toBe('Close');
});
it('should have all category translations', () => {
expect(DEFAULT_TRANSLATIONS.categoryNecessary).toBe('Necessary');
expect(DEFAULT_TRANSLATIONS.categoryFunctional).toBe('Functional');
expect(DEFAULT_TRANSLATIONS.categoryAnalytics).toBe('Analytics');
expect(DEFAULT_TRANSLATIONS.categoryMarketing).toBe('Marketing');
expect(DEFAULT_TRANSLATIONS.categoryPersonalisation).toBe('Personalisation');
});
it('should have category descriptions', () => {
expect(DEFAULT_TRANSLATIONS.categoryNecessaryDesc).toBeTruthy();
expect(DEFAULT_TRANSLATIONS.categoryFunctionalDesc).toBeTruthy();
expect(DEFAULT_TRANSLATIONS.categoryAnalyticsDesc).toBeTruthy();
expect(DEFAULT_TRANSLATIONS.categoryMarketingDesc).toBeTruthy();
expect(DEFAULT_TRANSLATIONS.categoryPersonalisationDesc).toBeTruthy();
});
it('should have cookie count template with placeholder', () => {
expect(DEFAULT_TRANSLATIONS.cookieCount).toContain('{{count}}');
});
});
describe('normaliseLocale', () => {
it('should extract language code from locale', () => {
expect(normaliseLocale('en-GB')).toBe('en');
expect(normaliseLocale('fr-FR')).toBe('fr');
expect(normaliseLocale('de-DE')).toBe('de');
});
it('should handle simple language codes', () => {
expect(normaliseLocale('en')).toBe('en');
expect(normaliseLocale('fr')).toBe('fr');
});
it('should lowercase the result', () => {
expect(normaliseLocale('EN-GB')).toBe('en');
expect(normaliseLocale('FR')).toBe('fr');
});
});
describe('detectLocale', () => {
it('should use data-locale attribute when present', () => {
const script = document.createElement('script');
script.setAttribute('data-site-id', 'site-1');
script.setAttribute('data-locale', 'fr-FR');
document.head.appendChild(script);
expect(detectLocale()).toBe('fr');
script.remove();
});
it('should fall back to navigator.language', () => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue('de-DE');
expect(detectLocale()).toBe('de');
});
it('should fall back to document lang', () => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue('');
document.documentElement.lang = 'es';
expect(detectLocale()).toBe('es');
document.documentElement.lang = '';
});
it('should default to en', () => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue('');
document.documentElement.lang = '';
expect(detectLocale()).toBe('en');
});
});
describe('fetchTranslations', () => {
it('should fetch and parse translations', async () => {
const mockTranslations = { title: 'Nous utilisons des cookies' };
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTranslations),
}));
const result = await fetchTranslations('https://cdn.example.com', 'fr');
expect(fetch).toHaveBeenCalledWith('https://cdn.example.com/translations-fr.json');
expect(result).toEqual(mockTranslations);
vi.unstubAllGlobals();
});
it('should return null on 404', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
const result = await fetchTranslations('https://cdn.example.com', 'zz');
expect(result).toBeNull();
vi.unstubAllGlobals();
});
it('should return null on network error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
const result = await fetchTranslations('https://cdn.example.com', 'fr');
expect(result).toBeNull();
vi.unstubAllGlobals();
});
});
describe('loadTranslations', () => {
it('should return defaults for English locale', async () => {
const t = await loadTranslations('https://cdn.example.com', 'en');
expect(t.title).toBe(DEFAULT_TRANSLATIONS.title);
expect(t.acceptAll).toBe(DEFAULT_TRANSLATIONS.acceptAll);
});
it('should merge remote translations over defaults', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ title: 'Wir verwenden Cookies', acceptAll: 'Alle akzeptieren' }),
}));
const t = await loadTranslations('https://cdn.example.com', 'de');
expect(t.title).toBe('Wir verwenden Cookies');
expect(t.acceptAll).toBe('Alle akzeptieren');
// Missing keys should fall back to English
expect(t.rejectAll).toBe(DEFAULT_TRANSLATIONS.rejectAll);
vi.unstubAllGlobals();
});
it('should fall back to defaults when fetch fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('fail')));
const t = await loadTranslations('https://cdn.example.com', 'fr');
expect(t.title).toBe(DEFAULT_TRANSLATIONS.title);
vi.unstubAllGlobals();
});
});
describe('interpolate', () => {
it('should replace placeholders with values', () => {
expect(interpolate('{{count}} cookies', { count: '12' })).toBe('12 cookies');
});
it('should handle multiple placeholders', () => {
expect(interpolate('{{a}} and {{b}}', { a: 'X', b: 'Y' })).toBe('X and Y');
});
it('should replace missing keys with empty string', () => {
expect(interpolate('Hello {{name}}', {})).toBe('Hello ');
});
it('should handle templates without placeholders', () => {
expect(interpolate('No placeholders', { key: 'value' })).toBe('No placeholders');
});
it('should handle empty template', () => {
expect(interpolate('', { key: 'value' })).toBe('');
});
});
});

View File

@@ -0,0 +1,157 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock the imports before loading the loader module
vi.mock('../blocker', () => ({
installBlocker: vi.fn(),
updateAcceptedCategories: vi.fn(),
}));
vi.mock('../consent', () => ({
hasConsent: vi.fn(),
readConsent: vi.fn(),
}));
vi.mock('../gcm', () => ({
buildDeniedDefaults: vi.fn(() => ({ analytics_storage: 'denied' })),
buildGcmStateFromCategories: vi.fn(() => ({ analytics_storage: 'granted' })),
setGcmDefaults: vi.fn(),
updateGcm: vi.fn(),
}));
import { installBlocker, updateAcceptedCategories } from '../blocker';
import { readConsent } from '../consent';
import { buildDeniedDefaults, buildGcmStateFromCategories, setGcmDefaults, updateGcm } from '../gcm';
describe('loader', () => {
beforeEach(() => {
vi.clearAllMocks();
window.dataLayer = [];
// @ts-expect-error — reset for test isolation
window.__consentos = undefined;
// Reset document.currentScript
Object.defineProperty(document, 'currentScript', {
value: null,
writable: true,
configurable: true,
});
});
// We can't import the loader directly (it self-executes as an IIFE),
// so we test the individual functions it composes instead.
describe('installBlocker integration', () => {
it('installBlocker should be callable', () => {
installBlocker();
expect(installBlocker).toHaveBeenCalled();
});
});
describe('GCM defaults', () => {
it('should build denied defaults and set them', () => {
const defaults = buildDeniedDefaults();
setGcmDefaults(defaults);
expect(buildDeniedDefaults).toHaveBeenCalled();
expect(setGcmDefaults).toHaveBeenCalledWith(defaults);
});
});
describe('existing consent flow', () => {
it('should update blocker and GCM when consent exists', () => {
const consent = {
accepted: ['necessary', 'analytics'],
rejected: ['marketing'],
visitorId: 'v123',
consentedAt: new Date().toISOString(),
};
vi.mocked(readConsent).mockReturnValue(consent as any);
const existingConsent = readConsent();
expect(existingConsent).toBeDefined();
if (existingConsent) {
updateAcceptedCategories(existingConsent.accepted as any);
const gcmState = buildGcmStateFromCategories(existingConsent.accepted);
updateGcm(gcmState);
expect(updateAcceptedCategories).toHaveBeenCalledWith(existingConsent.accepted);
expect(buildGcmStateFromCategories).toHaveBeenCalledWith(existingConsent.accepted);
expect(updateGcm).toHaveBeenCalled();
}
});
it('should not update blocker when no consent exists', () => {
vi.mocked(readConsent).mockReturnValue(null);
const existingConsent = readConsent();
expect(existingConsent).toBeNull();
// In this case, the loader would load the banner bundle
// updateAcceptedCategories should NOT have been called
expect(updateAcceptedCategories).not.toHaveBeenCalled();
});
});
describe('consent-change event', () => {
it('should dispatch consentos:consent-change custom event', () => {
const accepted = ['necessary', 'analytics'];
let receivedDetail: unknown = null;
document.addEventListener('consentos:consent-change', ((e: CustomEvent) => {
receivedDetail = e.detail;
}) as EventListener);
const event = new CustomEvent('consentos:consent-change', {
detail: { accepted },
});
document.dispatchEvent(event);
expect(receivedDetail).toEqual({ accepted });
});
it('should push to dataLayer if it exists', () => {
window.dataLayer = [];
const accepted = ['necessary', 'functional'];
window.dataLayer.push({
event: 'consentos_consent_change',
cmp_accepted_categories: accepted,
});
expect(window.dataLayer).toHaveLength(1);
expect(window.dataLayer[0]).toEqual({
event: 'consentos_consent_change',
cmp_accepted_categories: accepted,
});
});
});
describe('loadBannerBundle', () => {
it('should create a script element for the banner bundle', () => {
const script = document.createElement('script');
script.src = 'https://cdn.example.com/consent-bundle.js';
script.async = true;
document.head.appendChild(script);
const found = document.querySelector('script[src*="consent-bundle"]');
expect(found).not.toBeNull();
// Clean up
found?.remove();
});
});
describe('__cmp global', () => {
it('should expose the CMP context on window', () => {
window.__consentos = {
siteId: 'test-site-id',
apiBase: 'https://api.example.com',
cdnBase: 'https://cdn.example.com',
loaded: false,
};
expect(window.__consentos.siteId).toBe('test-site-id');
expect(window.__consentos.loaded).toBe(false);
});
});
});

View File

@@ -0,0 +1,212 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
hasConfigVersionChanged,
isConsentExpired,
needsReconsent,
} from '../reconsent';
import type { ConsentState, SiteConfig } from '../types';
/** Helper to build a minimal ConsentState for testing. */
function makeConsent(overrides: Partial<ConsentState> = {}): ConsentState {
return {
visitorId: 'test-visitor-id',
accepted: ['necessary', 'analytics'],
rejected: ['marketing'],
consentedAt: new Date().toISOString(),
bannerVersion: '0.1.0',
...overrides,
};
}
/** Helper to build a minimal SiteConfig for testing. */
function makeConfig(overrides: Partial<SiteConfig> = {}): SiteConfig {
return {
id: 'config-v1',
site_id: 'site-123',
blocking_mode: 'opt_in',
regional_modes: null,
tcf_enabled: false,
gpp_enabled: false,
gpp_supported_apis: [],
gpc_enabled: true,
gpc_jurisdictions: [],
gpc_global_honour: false,
gcm_enabled: true,
gcm_default: null,
shopify_privacy_enabled: false,
banner_config: null,
privacy_policy_url: null,
terms_url: null,
consent_expiry_days: 365,
consent_group_id: null,
ab_test: null,
initiator_map: null,
...overrides,
};
}
describe('reconsent', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-15T12:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
describe('isConsentExpired', () => {
it('should return false when consent is within expiry period', () => {
const consent = makeConsent({
consentedAt: '2026-06-01T12:00:00Z', // 14 days ago
});
const config = makeConfig({ consent_expiry_days: 365 });
expect(isConsentExpired(consent, config)).toBe(false);
});
it('should return true when consent has expired', () => {
const consent = makeConsent({
consentedAt: '2025-01-01T12:00:00Z', // ~530 days ago
});
const config = makeConfig({ consent_expiry_days: 365 });
expect(isConsentExpired(consent, config)).toBe(true);
});
it('should return true for exactly expired consent', () => {
// Consent given exactly 365 days ago + 1ms
const consentDate = new Date('2025-06-15T11:59:59.999Z');
const consent = makeConsent({ consentedAt: consentDate.toISOString() });
const config = makeConfig({ consent_expiry_days: 365 });
expect(isConsentExpired(consent, config)).toBe(true);
});
it('should return true for invalid consentedAt date', () => {
const consent = makeConsent({ consentedAt: 'not-a-date' });
const config = makeConfig();
expect(isConsentExpired(consent, config)).toBe(true);
});
it('should handle short expiry periods', () => {
const consent = makeConsent({
consentedAt: '2026-06-10T12:00:00Z', // 5 days ago
});
const config = makeConfig({ consent_expiry_days: 3 });
expect(isConsentExpired(consent, config)).toBe(true);
});
});
describe('hasConfigVersionChanged', () => {
it('should return false when versions match', () => {
const consent = makeConsent({ configVersion: 'config-v1' });
const config = makeConfig({ id: 'config-v1' });
expect(hasConfigVersionChanged(consent, config)).toBe(false);
});
it('should return true when versions differ', () => {
const consent = makeConsent({ configVersion: 'config-v1' });
const config = makeConfig({ id: 'config-v2' });
expect(hasConfigVersionChanged(consent, config)).toBe(true);
});
it('should return true when consent has no configVersion and config has an id', () => {
const consent = makeConsent(); // no configVersion
const config = makeConfig({ id: 'config-v1' });
expect(hasConfigVersionChanged(consent, config)).toBe(true);
});
it('should return false when consent has no configVersion and config id is empty', () => {
const consent = makeConsent(); // no configVersion
const config = makeConfig({ id: '' });
expect(hasConfigVersionChanged(consent, config)).toBe(false);
});
});
describe('needsReconsent', () => {
it('should return not required when all checks pass', () => {
const consent = makeConsent({
consentedAt: '2026-06-01T12:00:00Z',
configVersion: 'config-v1',
});
const config = makeConfig({
consent_expiry_days: 365,
id: 'config-v1',
});
const result = needsReconsent(consent, config);
expect(result.required).toBe(false);
expect(result.reasons).toEqual([]);
});
it('should detect expired consent', () => {
const consent = makeConsent({
consentedAt: '2024-01-01T00:00:00Z',
configVersion: 'config-v1',
});
const config = makeConfig({
consent_expiry_days: 365,
id: 'config-v1',
});
const result = needsReconsent(consent, config);
expect(result.required).toBe(true);
expect(result.reasons).toContain('expired');
});
it('should detect config version change', () => {
const consent = makeConsent({
consentedAt: '2026-06-01T12:00:00Z',
configVersion: 'config-v1',
});
const config = makeConfig({
consent_expiry_days: 365,
id: 'config-v2',
});
const result = needsReconsent(consent, config);
expect(result.required).toBe(true);
expect(result.reasons).toContain('config_changed');
});
it('should report multiple reasons', () => {
const consent = makeConsent({
consentedAt: '2024-01-01T00:00:00Z', // expired
configVersion: 'config-v1',
});
const config = makeConfig({
consent_expiry_days: 365,
id: 'config-v2', // config changed
});
const result = needsReconsent(consent, config);
expect(result.required).toBe(true);
expect(result.reasons).toContain('expired');
expect(result.reasons).toContain('config_changed');
expect(result.reasons).toHaveLength(2);
});
it('should not require re-consent for fresh consent with matching config', () => {
const consent = makeConsent({
consentedAt: new Date().toISOString(), // just now
configVersion: 'config-v1',
});
const config = makeConfig({
consent_expiry_days: 365,
id: 'config-v1',
});
const result = needsReconsent(consent, config);
expect(result.required).toBe(false);
expect(result.reasons).toEqual([]);
});
});
});

View File

@@ -0,0 +1,493 @@
/**
* Tests for client-side cookie reporter — CMP-23.
*
* Covers cookie parsing, storage enumeration, sampling, report building,
* report sending, and the full reporter lifecycle.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
type CookieReport,
type DiscoveredCookie,
type ReporterConfig,
buildReport,
collectAll,
enumerateLocalStorage,
enumerateSessionStorage,
getObservedScripts,
installScriptObserver,
parseCookies,
removeScriptObserver,
reportNow,
sendReport,
shouldSample,
startReporter,
} from '../reporter';
// ── Helpers ─────────────────────────────────────────────────────────
function makeConfig(
overrides: Partial<ReporterConfig> = {},
): ReporterConfig {
return {
siteId: 'test-site-id',
apiBase: 'https://api.example.com/api/v1',
sampleRate: 1.0, // Always sample in tests
collectDelay: 0,
includeLocalStorage: true,
includeSessionStorage: true,
...overrides,
};
}
// ── Cookie parsing ──────────────────────────────────────────────────
describe('parseCookies', () => {
beforeEach(() => {
// Clear all cookies
document.cookie.split(';').forEach((c) => {
const name = c.split('=')[0].trim();
if (name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}
});
});
afterEach(() => {
document.cookie.split(';').forEach((c) => {
const name = c.split('=')[0].trim();
if (name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}
});
});
it('should return empty array when no cookies exist', () => {
// After clearing, parseCookies should return empty or only empty entries
const result = parseCookies();
// Filter out any empty-name artefacts from jsdom
const named = result.filter((c) => c.name.length > 0);
expect(named).toEqual([]);
});
it('should parse a single cookie', () => {
document.cookie = '_ga=GA1.2.123456789.1234567890';
const result = parseCookies();
const ga = result.find((c) => c.name === '_ga');
expect(ga).toBeDefined();
expect(ga!.storage_type).toBe('cookie');
expect(ga!.domain).toBe('localhost');
expect(ga!.value_length).toBeGreaterThan(0);
});
it('should parse multiple cookies', () => {
document.cookie = '_ga=value1';
document.cookie = '_gid=value2';
const result = parseCookies();
const names = result.map((c) => c.name);
expect(names).toContain('_ga');
expect(names).toContain('_gid');
});
it('should handle cookies with equals in value', () => {
document.cookie = 'data=key=value';
const result = parseCookies();
const data = result.find((c) => c.name === 'data');
expect(data).toBeDefined();
expect(data!.value_length).toBe('key=value'.length);
});
});
// ── localStorage enumeration ────────────────────────────────────────
describe('enumerateLocalStorage', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('should return empty array when localStorage is empty', () => {
expect(enumerateLocalStorage()).toEqual([]);
});
it('should enumerate localStorage keys', () => {
localStorage.setItem('analytics_id', 'abc123');
localStorage.setItem('theme', 'dark');
const result = enumerateLocalStorage();
expect(result).toHaveLength(2);
const names = result.map((i) => i.name);
expect(names).toContain('analytics_id');
expect(names).toContain('theme');
expect(result[0].storage_type).toBe('local_storage');
});
it('should report correct value lengths', () => {
localStorage.setItem('key', 'hello');
const result = enumerateLocalStorage();
expect(result[0].value_length).toBe(5);
});
});
// ── sessionStorage enumeration ──────────────────────────────────────
describe('enumerateSessionStorage', () => {
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
sessionStorage.clear();
});
it('should return empty array when sessionStorage is empty', () => {
expect(enumerateSessionStorage()).toEqual([]);
});
it('should enumerate sessionStorage keys', () => {
sessionStorage.setItem('session_token', 'xyz');
const result = enumerateSessionStorage();
expect(result).toHaveLength(1);
expect(result[0].name).toBe('session_token');
expect(result[0].storage_type).toBe('session_storage');
});
});
// ── collectAll ──────────────────────────────────────────────────────
describe('collectAll', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
afterEach(() => {
localStorage.clear();
sessionStorage.clear();
});
it('should collect from all storage types', () => {
document.cookie = '_ga=test';
localStorage.setItem('ls_key', 'value');
sessionStorage.setItem('ss_key', 'value');
const config = makeConfig();
const result = collectAll(config);
const types = new Set(result.map((i) => i.storage_type));
expect(types).toContain('cookie');
expect(types).toContain('local_storage');
expect(types).toContain('session_storage');
});
it('should exclude localStorage when disabled', () => {
localStorage.setItem('ls_key', 'value');
const config = makeConfig({ includeLocalStorage: false });
const result = collectAll(config);
const hasLocal = result.some((i) => i.storage_type === 'local_storage');
expect(hasLocal).toBe(false);
});
it('should exclude sessionStorage when disabled', () => {
sessionStorage.setItem('ss_key', 'value');
const config = makeConfig({ includeSessionStorage: false });
const result = collectAll(config);
const hasSession = result.some(
(i) => i.storage_type === 'session_storage',
);
expect(hasSession).toBe(false);
});
});
// ── Report building ─────────────────────────────────────────────────
describe('buildReport', () => {
it('should build a valid report', () => {
const config = makeConfig();
const cookies: DiscoveredCookie[] = [
{
name: '_ga',
domain: 'example.com',
storage_type: 'cookie',
value_length: 30,
},
];
const report = buildReport(config, cookies);
expect(report.site_id).toBe('test-site-id');
expect(report.cookies).toHaveLength(1);
expect(report.collected_at).toBeTruthy();
expect(report.page_url).toBeTruthy();
expect(report.user_agent).toBeTruthy();
});
it('should include the current page URL', () => {
const config = makeConfig();
const report = buildReport(config, []);
expect(report.page_url).toContain('localhost');
});
});
// ── Sampling ────────────────────────────────────────────────────────
describe('shouldSample', () => {
it('should always sample at rate 1.0', () => {
// Run 100 times — all should be true
for (let i = 0; i < 100; i++) {
expect(shouldSample(1.0)).toBe(true);
}
});
it('should never sample at rate 0.0', () => {
for (let i = 0; i < 100; i++) {
expect(shouldSample(0.0)).toBe(false);
}
});
it('should sample approximately at the given rate', () => {
// With 0.5, we'd expect roughly half
let sampled = 0;
const runs = 1000;
for (let i = 0; i < runs; i++) {
if (shouldSample(0.5)) sampled++;
}
// Allow generous margin (30-70%)
expect(sampled).toBeGreaterThan(runs * 0.3);
expect(sampled).toBeLessThan(runs * 0.7);
});
});
// ── Report sending ──────────────────────────────────────────────────
describe('sendReport', () => {
it('should use sendBeacon when available', async () => {
const beaconMock = vi.fn().mockReturnValue(true);
Object.defineProperty(navigator, 'sendBeacon', {
value: beaconMock,
writable: true,
configurable: true,
});
const report: CookieReport = {
site_id: 'test',
page_url: 'https://example.com',
cookies: [],
collected_at: new Date().toISOString(),
user_agent: 'test-agent',
};
const result = await sendReport(
'https://api.example.com/api/v1',
report,
);
expect(result).toBe(true);
expect(beaconMock).toHaveBeenCalledWith(
'https://api.example.com/api/v1/scanner/report',
expect.any(Blob),
);
});
it('should fall back to fetch when sendBeacon is unavailable', async () => {
Object.defineProperty(navigator, 'sendBeacon', {
value: undefined,
writable: true,
configurable: true,
});
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
globalThis.fetch = fetchMock;
const report: CookieReport = {
site_id: 'test',
page_url: 'https://example.com',
cookies: [],
collected_at: new Date().toISOString(),
user_agent: 'test-agent',
};
const result = await sendReport(
'https://api.example.com/api/v1',
report,
);
expect(result).toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.com/api/v1/scanner/report',
expect.objectContaining({
method: 'POST',
keepalive: true,
}),
);
});
it('should return false on fetch failure', async () => {
Object.defineProperty(navigator, 'sendBeacon', {
value: undefined,
writable: true,
configurable: true,
});
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network error'));
const report: CookieReport = {
site_id: 'test',
page_url: 'https://example.com',
cookies: [],
collected_at: new Date().toISOString(),
user_agent: 'test-agent',
};
const result = await sendReport(
'https://api.example.com/api/v1',
report,
);
expect(result).toBe(false);
});
});
// ── Script observer ─────────────────────────────────────────────────
describe('scriptObserver', () => {
afterEach(() => {
removeScriptObserver();
});
it('should install and remove observer without errors', () => {
// jsdom doesn't have full PerformanceObserver, but shouldn't throw
installScriptObserver();
expect(getObservedScripts()).toEqual([]);
removeScriptObserver();
expect(getObservedScripts()).toEqual([]);
});
it('should clear observed scripts on remove', () => {
installScriptObserver();
removeScriptObserver();
expect(getObservedScripts()).toEqual([]);
});
});
// ── startReporter ───────────────────────────────────────────────────
describe('startReporter', () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
sessionStorage.clear();
});
afterEach(() => {
vi.useRealTimers();
removeScriptObserver();
});
it('should not report when sample rate is 0', () => {
const fetchMock = vi.fn();
globalThis.fetch = fetchMock;
Object.defineProperty(navigator, 'sendBeacon', {
value: undefined,
writable: true,
configurable: true,
});
startReporter({
siteId: 'test',
apiBase: 'https://api.example.com/api/v1',
sampleRate: 0,
});
vi.advanceTimersByTime(5000);
expect(fetchMock).not.toHaveBeenCalled();
});
it('should report after delay when sampled', () => {
document.cookie = '_test_reporter=value';
const beaconMock = vi.fn().mockReturnValue(true);
Object.defineProperty(navigator, 'sendBeacon', {
value: beaconMock,
writable: true,
configurable: true,
});
startReporter({
siteId: 'test',
apiBase: 'https://api.example.com/api/v1',
sampleRate: 1.0,
collectDelay: 2000,
});
// Before delay — should not have reported
expect(beaconMock).not.toHaveBeenCalled();
// After delay — should report
vi.advanceTimersByTime(2000);
expect(beaconMock).toHaveBeenCalled();
// Clean up
document.cookie =
'_test_reporter=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
});
});
// ── reportNow ───────────────────────────────────────────────────────
describe('reportNow', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
it('should return null when no items found', async () => {
// Clear cookies
document.cookie.split(';').forEach((c) => {
const name = c.split('=')[0].trim();
if (name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}
});
const result = await reportNow({
siteId: 'test',
apiBase: 'https://api.example.com/api/v1',
includeLocalStorage: false,
includeSessionStorage: false,
});
// May be null if no cookies remain, or a report if jsdom has residual cookies
if (result !== null) {
expect(result.cookies.length).toBeGreaterThan(0);
}
});
it('should collect and send a report', async () => {
document.cookie = '_report_test=value';
const beaconMock = vi.fn().mockReturnValue(true);
Object.defineProperty(navigator, 'sendBeacon', {
value: beaconMock,
writable: true,
configurable: true,
});
const result = await reportNow({
siteId: 'my-site',
apiBase: 'https://api.example.com/api/v1',
});
expect(result).not.toBeNull();
expect(result!.site_id).toBe('my-site');
expect(result!.cookies.length).toBeGreaterThan(0);
expect(beaconMock).toHaveBeenCalled();
// Clean up
document.cookie =
'_report_test=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
});
});

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { buildShopifyConsent, isShopifyPrivacyAvailable, updateShopifyConsent } from '../shopify';
import type { CategorySlug } from '../types';
describe('shopify', () => {
const mockSetTrackingConsent = vi.fn();
const mockCurrentVisitorConsent = vi.fn();
beforeEach(() => {
(window as any).Shopify = {
customerPrivacy: {
setTrackingConsent: mockSetTrackingConsent,
currentVisitorConsent: mockCurrentVisitorConsent,
analyticsProcessingAllowed: () => false,
marketingAllowed: () => false,
preferencesProcessingAllowed: () => false,
saleOfDataAllowed: () => false,
getRegion: () => 'CA',
},
};
});
afterEach(() => {
delete (window as any).Shopify;
vi.restoreAllMocks();
});
describe('isShopifyPrivacyAvailable', () => {
it('returns true when Shopify API is present', () => {
expect(isShopifyPrivacyAvailable()).toBe(true);
});
it('returns false when Shopify is not on window', () => {
delete (window as any).Shopify;
expect(isShopifyPrivacyAvailable()).toBe(false);
});
it('returns false when customerPrivacy is missing', () => {
(window as any).Shopify = {};
expect(isShopifyPrivacyAvailable()).toBe(false);
});
});
describe('buildShopifyConsent', () => {
it('maps accept all to all yes', () => {
const accepted: CategorySlug[] = ['necessary', 'functional', 'analytics', 'marketing', 'personalisation'];
const result = buildShopifyConsent(accepted);
expect(result).toEqual({
preferences: 'yes',
analytics: 'yes',
marketing: 'yes',
sale_of_data: 'yes',
});
});
it('maps reject all (necessary only) to all no', () => {
const accepted: CategorySlug[] = ['necessary'];
const result = buildShopifyConsent(accepted);
expect(result).toEqual({
preferences: 'no',
analytics: 'no',
marketing: 'no',
sale_of_data: 'no',
});
});
it('maps functional to preferences', () => {
const accepted: CategorySlug[] = ['necessary', 'functional'];
const result = buildShopifyConsent(accepted);
expect(result.preferences).toBe('yes');
expect(result.analytics).toBe('no');
expect(result.marketing).toBe('no');
});
it('maps personalisation to sale_of_data', () => {
const accepted: CategorySlug[] = ['necessary', 'personalisation'];
const result = buildShopifyConsent(accepted);
expect(result.sale_of_data).toBe('yes');
expect(result.marketing).toBe('no');
});
it('maps marketing to both marketing and sale_of_data', () => {
const accepted: CategorySlug[] = ['necessary', 'marketing'];
const result = buildShopifyConsent(accepted);
expect(result.marketing).toBe('yes');
expect(result.sale_of_data).toBe('yes');
});
});
describe('updateShopifyConsent', () => {
it('calls setTrackingConsent with mapped values', () => {
const accepted: CategorySlug[] = ['necessary', 'analytics', 'marketing'];
updateShopifyConsent(accepted);
expect(mockSetTrackingConsent).toHaveBeenCalledWith(
{
preferences: 'no',
analytics: 'yes',
marketing: 'yes',
sale_of_data: 'yes',
},
expect.any(Function),
);
});
it('does nothing when Shopify API is not available', () => {
delete (window as any).Shopify;
updateShopifyConsent(['necessary', 'analytics']);
expect(mockSetTrackingConsent).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,856 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
BitReader,
BitWriter,
RestrictionType,
base64urlToBytes,
bytesToBase64url,
createTCModel,
decodeTCString,
decisecondsToMs,
encodeTCString,
getTcString,
installTcfApi,
msToDeciseconds,
removeTcfApi,
setTcfDisplayStatus,
updateTcfConsent,
} from '../tcf';
import type { TCModel, TcfApiCallback } from '../tcf';
// ── BitWriter / BitReader ────────────────────────────────────────────
describe('BitWriter', () => {
it('writes single bits correctly', () => {
const w = new BitWriter();
w.writeBool(true);
w.writeBool(false);
w.writeBool(true);
w.writeBool(true);
w.writeBool(false);
w.writeBool(false);
w.writeBool(true);
w.writeBool(false);
const bytes = w.toBytes();
expect(bytes.length).toBe(1);
// 10110010 = 0xB2 = 178
expect(bytes[0]).toBe(0b10110010);
});
it('writes multi-bit integers', () => {
const w = new BitWriter();
w.writeInt(2, 6); // 000010
const bytes = w.toBytes();
// 000010 + 00 (padding) = 00001000 = 8
expect(bytes[0]).toBe(0b00001000);
});
it('writes integers across byte boundaries', () => {
const w = new BitWriter();
w.writeInt(0b11111111, 8);
w.writeInt(0b1010, 4);
const bytes = w.toBytes();
expect(bytes.length).toBe(2);
expect(bytes[0]).toBe(0xff);
// 1010 + 0000 (padding) = 10100000
expect(bytes[1]).toBe(0b10100000);
});
it('writes a bitfield from a Set', () => {
const w = new BitWriter();
w.writeBitfield(new Set([1, 3, 5]), 8);
const bytes = w.toBytes();
// bits: 1 0 1 0 1 0 0 0 = 0xA8
expect(bytes[0]).toBe(0b10101000);
});
it('writes two-letter codes', () => {
const w = new BitWriter();
w.writeLetters('EN');
const bytes = w.toBytes();
// E=4 (000100), N=13 (001101) → 000100 001101 → 00010000 1101(0000)
const r = new BitReader(bytes);
expect(r.readInt(6)).toBe(4); // E
expect(r.readInt(6)).toBe(13); // N
});
it('handles empty writes', () => {
const w = new BitWriter();
const bytes = w.toBytes();
expect(bytes.length).toBe(0);
});
it('pads incomplete last byte', () => {
const w = new BitWriter();
w.writeBool(true);
const bytes = w.toBytes();
expect(bytes.length).toBe(1);
// 1 + 0000000 (padding) = 10000000
expect(bytes[0]).toBe(0b10000000);
});
});
describe('BitReader', () => {
it('reads single bits', () => {
const r = new BitReader(new Uint8Array([0b10110010]));
expect(r.readBool()).toBe(true);
expect(r.readBool()).toBe(false);
expect(r.readBool()).toBe(true);
expect(r.readBool()).toBe(true);
expect(r.readBool()).toBe(false);
expect(r.readBool()).toBe(false);
expect(r.readBool()).toBe(true);
expect(r.readBool()).toBe(false);
});
it('reads multi-bit integers', () => {
const r = new BitReader(new Uint8Array([0b00001000]));
expect(r.readInt(6)).toBe(2);
});
it('reads across byte boundaries', () => {
const r = new BitReader(new Uint8Array([0xff, 0b10100000]));
expect(r.readInt(8)).toBe(255);
expect(r.readInt(4)).toBe(0b1010);
});
it('reads a bitfield into a Set', () => {
const r = new BitReader(new Uint8Array([0b10101000]));
const ids = r.readBitfield(8);
expect(ids).toEqual(new Set([1, 3, 5]));
});
it('reads two-letter codes', () => {
// E=4 (000100), N=13 (001101) → 00010000 11010000
const r = new BitReader(new Uint8Array([0b00010000, 0b11010000]));
expect(r.readLetters()).toBe('EN');
});
it('hasRemaining checks available bits', () => {
const r = new BitReader(new Uint8Array([0xff]));
expect(r.hasRemaining(8)).toBe(true);
expect(r.hasRemaining(9)).toBe(false);
r.readInt(4);
expect(r.hasRemaining(4)).toBe(true);
expect(r.hasRemaining(5)).toBe(false);
});
it('reads zero when past end of buffer', () => {
const r = new BitReader(new Uint8Array([0xff]));
r.readInt(8); // consume all
expect(r.readInt(4)).toBe(0);
});
});
// ── Base64url ────────────────────────────────────────────────────────
describe('base64url encoding', () => {
it('round-trips bytes correctly', () => {
const original = new Uint8Array([0, 1, 2, 127, 128, 255]);
const encoded = bytesToBase64url(original);
const decoded = base64urlToBytes(encoded);
expect(decoded).toEqual(original);
});
it('encodes empty array', () => {
expect(bytesToBase64url(new Uint8Array([]))).toBe('');
});
it('decodes empty string', () => {
expect(base64urlToBytes('')).toEqual(new Uint8Array([]));
});
it('produces URL-safe characters (no +, /, =)', () => {
const bytes = new Uint8Array(256);
for (let i = 0; i < 256; i++) bytes[i] = i;
const encoded = bytesToBase64url(bytes);
expect(encoded).not.toContain('+');
expect(encoded).not.toContain('/');
expect(encoded).not.toContain('=');
});
it('round-trips single byte', () => {
const original = new Uint8Array([42]);
expect(base64urlToBytes(bytesToBase64url(original))).toEqual(original);
});
it('round-trips two bytes', () => {
const original = new Uint8Array([200, 100]);
expect(base64urlToBytes(bytesToBase64url(original))).toEqual(original);
});
});
// ── Timestamp helpers ────────────────────────────────────────────────
describe('timestamp conversion', () => {
it('converts ms to deciseconds', () => {
expect(msToDeciseconds(1000)).toBe(10);
expect(msToDeciseconds(100)).toBe(1);
expect(msToDeciseconds(150)).toBe(2); // rounds
});
it('converts deciseconds to ms', () => {
expect(decisecondsToMs(10)).toBe(1000);
expect(decisecondsToMs(1)).toBe(100);
});
it('round-trips approximately', () => {
const now = Date.now();
const ds = msToDeciseconds(now);
const back = decisecondsToMs(ds);
// Within 100ms accuracy (one decisecond)
expect(Math.abs(back - now)).toBeLessThan(100);
});
});
// ── createTCModel ────────────────────────────────────────────────────
describe('createTCModel', () => {
it('creates a model with defaults', () => {
const model = createTCModel();
expect(model.version).toBe(2);
expect(model.tcfPolicyVersion).toBe(4);
expect(model.isServiceSpecific).toBe(true);
expect(model.consentLanguage).toBe('EN');
expect(model.publisherCC).toBe('GB');
expect(model.purposeConsents.size).toBe(0);
expect(model.vendorConsents.size).toBe(0);
});
it('accepts overrides', () => {
const model = createTCModel({
cmpId: 42,
consentLanguage: 'FR',
purposeConsents: new Set([1, 2, 3]),
});
expect(model.cmpId).toBe(42);
expect(model.consentLanguage).toBe('FR');
expect(model.purposeConsents).toEqual(new Set([1, 2, 3]));
// Defaults still apply
expect(model.version).toBe(2);
});
it('sets created and lastUpdated to now', () => {
const before = msToDeciseconds(Date.now());
const model = createTCModel();
const after = msToDeciseconds(Date.now());
expect(model.created).toBeGreaterThanOrEqual(before);
expect(model.created).toBeLessThanOrEqual(after);
expect(model.lastUpdated).toBe(model.created);
});
});
// ── Encode / Decode round-trip ───────────────────────────────────────
describe('encodeTCString / decodeTCString', () => {
it('round-trips a minimal model', () => {
const model = createTCModel({
created: 100000,
lastUpdated: 100000,
cmpId: 10,
cmpVersion: 2,
});
const tcString = encodeTCString(model);
expect(typeof tcString).toBe('string');
expect(tcString.length).toBeGreaterThan(0);
const decoded = decodeTCString(tcString);
expect(decoded.version).toBe(2);
expect(decoded.created).toBe(100000);
expect(decoded.lastUpdated).toBe(100000);
expect(decoded.cmpId).toBe(10);
expect(decoded.cmpVersion).toBe(2);
expect(decoded.consentLanguage).toBe('EN');
expect(decoded.publisherCC).toBe('GB');
expect(decoded.isServiceSpecific).toBe(true);
expect(decoded.tcfPolicyVersion).toBe(4);
});
it('round-trips purpose consents', () => {
const model = createTCModel({
created: 200000,
lastUpdated: 200000,
purposeConsents: new Set([1, 2, 3, 7, 10]),
purposeLegitimateInterests: new Set([2, 4, 6]),
});
const decoded = decodeTCString(encodeTCString(model));
expect(decoded.purposeConsents).toEqual(new Set([1, 2, 3, 7, 10]));
expect(decoded.purposeLegitimateInterests).toEqual(new Set([2, 4, 6]));
});
it('round-trips vendor consents', () => {
const model = createTCModel({
created: 300000,
lastUpdated: 300000,
vendorConsents: new Set([1, 5, 10, 100]),
vendorLegitimateInterests: new Set([2, 50]),
});
const decoded = decodeTCString(encodeTCString(model));
expect(decoded.vendorConsents).toEqual(new Set([1, 5, 10, 100]));
expect(decoded.vendorLegitimateInterests).toEqual(new Set([2, 50]));
});
it('round-trips special feature opt-ins', () => {
const model = createTCModel({
created: 400000,
lastUpdated: 400000,
specialFeatureOptIns: new Set([1, 2]),
});
const decoded = decodeTCString(encodeTCString(model));
expect(decoded.specialFeatureOptIns).toEqual(new Set([1, 2]));
});
it('round-trips consent language and publisher CC', () => {
const model = createTCModel({
created: 500000,
lastUpdated: 500000,
consentLanguage: 'FR',
publisherCC: 'DE',
});
const decoded = decodeTCString(encodeTCString(model));
expect(decoded.consentLanguage).toBe('FR');
expect(decoded.publisherCC).toBe('DE');
});
it('round-trips boolean flags', () => {
const model = createTCModel({
created: 600000,
lastUpdated: 600000,
isServiceSpecific: false,
useNonStandardTexts: true,
purposeOneTreatment: true,
});
const decoded = decodeTCString(encodeTCString(model));
expect(decoded.isServiceSpecific).toBe(false);
expect(decoded.useNonStandardTexts).toBe(true);
expect(decoded.purposeOneTreatment).toBe(true);
});
it('round-trips publisher restrictions', () => {
const model = createTCModel({
created: 700000,
lastUpdated: 700000,
publisherRestrictions: [
{
purposeId: 1,
restrictionType: RestrictionType.REQUIRE_CONSENT,
vendorIds: new Set([10, 20, 30]),
},
{
purposeId: 3,
restrictionType: RestrictionType.NOT_ALLOWED,
vendorIds: new Set([5]),
},
],
});
const decoded = decodeTCString(encodeTCString(model));
expect(decoded.publisherRestrictions.length).toBe(2);
expect(decoded.publisherRestrictions[0].purposeId).toBe(1);
expect(decoded.publisherRestrictions[0].restrictionType).toBe(
RestrictionType.REQUIRE_CONSENT
);
expect(decoded.publisherRestrictions[0].vendorIds).toEqual(new Set([10, 20, 30]));
expect(decoded.publisherRestrictions[1].purposeId).toBe(3);
expect(decoded.publisherRestrictions[1].vendorIds).toEqual(new Set([5]));
});
it('handles empty vendor sets', () => {
const model = createTCModel({
created: 800000,
lastUpdated: 800000,
vendorConsents: new Set(),
vendorLegitimateInterests: new Set(),
});
const decoded = decodeTCString(encodeTCString(model));
expect(decoded.vendorConsents.size).toBe(0);
expect(decoded.vendorLegitimateInterests.size).toBe(0);
});
it('handles no publisher restrictions', () => {
const model = createTCModel({
created: 900000,
lastUpdated: 900000,
publisherRestrictions: [],
});
const decoded = decodeTCString(encodeTCString(model));
expect(decoded.publisherRestrictions.length).toBe(0);
});
it('round-trips a fully populated model', () => {
const model = createTCModel({
created: 1000000,
lastUpdated: 1000001,
cmpId: 300,
cmpVersion: 5,
consentScreen: 2,
consentLanguage: 'DE',
vendorListVersion: 150,
tcfPolicyVersion: 4,
isServiceSpecific: false,
useNonStandardTexts: false,
specialFeatureOptIns: new Set([1, 2]),
purposeConsents: new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
purposeLegitimateInterests: new Set([2, 7, 9, 10]),
purposeOneTreatment: true,
publisherCC: 'FR',
vendorConsents: new Set([1, 2, 3, 10, 25, 50, 100, 200, 500]),
vendorLegitimateInterests: new Set([1, 10, 50]),
publisherRestrictions: [
{
purposeId: 2,
restrictionType: RestrictionType.REQUIRE_LEGITIMATE_INTEREST,
vendorIds: new Set([100, 101, 102]),
},
],
});
const tcString = encodeTCString(model);
const decoded = decodeTCString(tcString);
expect(decoded.version).toBe(2);
expect(decoded.created).toBe(1000000);
expect(decoded.lastUpdated).toBe(1000001);
expect(decoded.cmpId).toBe(300);
expect(decoded.cmpVersion).toBe(5);
expect(decoded.consentScreen).toBe(2);
expect(decoded.consentLanguage).toBe('DE');
expect(decoded.vendorListVersion).toBe(150);
expect(decoded.tcfPolicyVersion).toBe(4);
expect(decoded.isServiceSpecific).toBe(false);
expect(decoded.purposeConsents).toEqual(new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]));
expect(decoded.purposeLegitimateInterests).toEqual(new Set([2, 7, 9, 10]));
expect(decoded.purposeOneTreatment).toBe(true);
expect(decoded.publisherCC).toBe('FR');
expect(decoded.vendorConsents).toEqual(
new Set([1, 2, 3, 10, 25, 50, 100, 200, 500])
);
expect(decoded.vendorLegitimateInterests).toEqual(new Set([1, 10, 50]));
expect(decoded.specialFeatureOptIns).toEqual(new Set([1, 2]));
expect(decoded.publisherRestrictions[0].vendorIds).toEqual(
new Set([100, 101, 102])
);
});
it('only parses the core segment when dots are present', () => {
const model = createTCModel({ created: 1100000, lastUpdated: 1100000 });
const tcString = encodeTCString(model);
// Append a fake disclosed vendors segment
const withSegments = `${tcString}.FAKE_SEGMENT`;
const decoded = decodeTCString(withSegments);
expect(decoded.version).toBe(2);
expect(decoded.created).toBe(1100000);
});
});
// ── __tcfapi interface ───────────────────────────────────────────────
describe('__tcfapi interface', () => {
beforeEach(() => {
removeTcfApi();
});
afterEach(() => {
removeTcfApi();
});
describe('installTcfApi', () => {
it('installs __tcfapi on window', () => {
installTcfApi(42, 1);
expect(typeof window.__tcfapi).toBe('function');
});
it('processes queued calls from the stub', () => {
const queue: unknown[][] = [];
window.__tcfapiQueue = queue;
const callback = vi.fn();
queue.push(['ping', 2, callback]);
installTcfApi(42, 1);
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({ cmpLoaded: true }),
true
);
});
it('clears the queue after processing', () => {
const queue: unknown[][] = [['ping', 2, vi.fn()]];
window.__tcfapiQueue = queue;
installTcfApi(42, 1);
expect(queue.length).toBe(0);
});
});
describe('ping command', () => {
it('returns CMP status', () => {
installTcfApi(42, 3);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('ping', 2, callback);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
gdprApplies: true,
cmpLoaded: true,
cmpStatus: 'loaded',
displayStatus: 'hidden',
apiVersion: '2.2',
cmpVersion: 3,
cmpId: 42,
tcfPolicyVersion: 4,
}),
true
);
});
it('respects gdprApplies parameter', () => {
installTcfApi(42, 1, false);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('ping', 2, callback);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({ gdprApplies: false }),
true
);
});
});
describe('getTCData command', () => {
it('returns cmpuishown when no consent', () => {
installTcfApi(42, 1);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('getTCData', 2, callback);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
eventStatus: 'cmpuishown',
cmpId: 42,
tcString: '',
}),
true
);
});
it('returns tcloaded after consent is set', () => {
installTcfApi(42, 1);
const model = createTCModel({
created: 100000,
lastUpdated: 100000,
cmpId: 42,
purposeConsents: new Set([1, 2]),
});
updateTcfConsent(model);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('getTCData', 2, callback);
const result = callback.mock.calls[0][0];
expect(result.eventStatus).toBe('tcloaded');
expect(result.tcString).toBeTruthy();
expect(result.purpose.consents['1']).toBe(true);
expect(result.purpose.consents['2']).toBe(true);
expect(result.purpose.consents['3']).toBe(false);
});
it('includes vendor consent data', () => {
installTcfApi(42, 1);
const model = createTCModel({
created: 100000,
lastUpdated: 100000,
vendorConsents: new Set([1, 5, 10]),
vendorLegitimateInterests: new Set([2, 5]),
});
updateTcfConsent(model);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('getTCData', 2, callback);
const result = callback.mock.calls[0][0];
expect(result.vendor.consents['1']).toBe(true);
expect(result.vendor.consents['5']).toBe(true);
expect(result.vendor.consents['10']).toBe(true);
expect(result.vendor.consents['2']).toBe(false);
expect(result.vendor.legitimateInterests['2']).toBe(true);
expect(result.vendor.legitimateInterests['5']).toBe(true);
});
it('includes special feature opt-in data', () => {
installTcfApi(42, 1);
const model = createTCModel({
created: 100000,
lastUpdated: 100000,
specialFeatureOptIns: new Set([1, 2]),
});
updateTcfConsent(model);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('getTCData', 2, callback);
const result = callback.mock.calls[0][0];
expect(result.specialFeatureOptins['1']).toBe(true);
expect(result.specialFeatureOptins['2']).toBe(true);
expect(result.specialFeatureOptins['3']).toBe(false);
});
});
describe('addEventListener command', () => {
it('assigns a listener ID and returns current TC data', () => {
installTcfApi(42, 1);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('addEventListener', 2, callback);
expect(callback).toHaveBeenCalledOnce();
const result = callback.mock.calls[0][0];
expect(result.listenerId).toBe(1);
expect(result.eventStatus).toBe('cmpuishown');
});
it('notifies listeners when consent is updated', () => {
installTcfApi(42, 1);
const listener = vi.fn();
const api = window.__tcfapi as Function;
api('addEventListener', 2, listener);
// Initial call
expect(listener).toHaveBeenCalledOnce();
// Update consent
const model = createTCModel({
created: 100000,
lastUpdated: 100000,
purposeConsents: new Set([1]),
});
updateTcfConsent(model);
// Should be called again with useractioncomplete
expect(listener).toHaveBeenCalledTimes(2);
const updateResult = listener.mock.calls[1][0];
expect(updateResult.eventStatus).toBe('useractioncomplete');
expect(updateResult.tcString).toBeTruthy();
});
it('assigns incrementing listener IDs', () => {
installTcfApi(42, 1);
const cb1 = vi.fn();
const cb2 = vi.fn();
const api = window.__tcfapi as Function;
api('addEventListener', 2, cb1);
api('addEventListener', 2, cb2);
expect(cb1.mock.calls[0][0].listenerId).toBe(1);
expect(cb2.mock.calls[0][0].listenerId).toBe(2);
});
it('swallows errors in listener callbacks', () => {
installTcfApi(42, 1);
const badListener = vi.fn(() => {
throw new Error('boom');
});
const goodListener = vi.fn();
const api = window.__tcfapi as Function;
api('addEventListener', 2, badListener);
api('addEventListener', 2, goodListener);
// Update consent - should not throw
const model = createTCModel({ created: 100000, lastUpdated: 100000 });
expect(() => updateTcfConsent(model)).not.toThrow();
// Good listener still called
expect(goodListener).toHaveBeenCalledTimes(2);
});
});
describe('removeEventListener command', () => {
it('removes a listener by ID', () => {
installTcfApi(42, 1);
const listener = vi.fn();
const api = window.__tcfapi as Function;
api('addEventListener', 2, listener);
const listenerId = listener.mock.calls[0][0].listenerId;
const removeCallback = vi.fn();
api('removeEventListener', 2, removeCallback, listenerId);
expect(removeCallback).toHaveBeenCalledWith(true, true);
// Listener should not be called on updates
listener.mockClear();
updateTcfConsent(createTCModel({ created: 100000, lastUpdated: 100000 }));
expect(listener).not.toHaveBeenCalled();
});
it('returns false for non-existent listener ID', () => {
installTcfApi(42, 1);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('removeEventListener', 2, callback, 999);
expect(callback).toHaveBeenCalledWith(false, false);
});
});
describe('version checking', () => {
it('rejects non-v2 API calls', () => {
installTcfApi(42, 1);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('ping', 1, callback);
expect(callback).toHaveBeenCalledWith(false, false);
});
});
describe('unknown commands', () => {
it('calls back with false for unknown commands', () => {
installTcfApi(42, 1);
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('unknownCommand', 2, callback);
expect(callback).toHaveBeenCalledWith(false, false);
});
});
describe('uninitialised state', () => {
it('calls back with false when API not installed', () => {
// Do NOT call installTcfApi — directly test the handler via a mock
// Since removeTcfApi is called in beforeEach, apiState is null
// We need to call installTcfApi then removeTcfApi to reset, then
// try calling a cached reference (but __tcfapi is deleted).
// Instead, test that getTcString returns empty when uninitialised.
expect(getTcString()).toBe('');
});
});
});
// ── updateTcfConsent ─────────────────────────────────────────────────
describe('updateTcfConsent', () => {
beforeEach(() => {
removeTcfApi();
});
afterEach(() => {
removeTcfApi();
});
it('returns a valid TC string', () => {
installTcfApi(42, 1);
const model = createTCModel({
created: 100000,
lastUpdated: 100000,
cmpId: 42,
purposeConsents: new Set([1, 2, 3]),
});
const tcString = updateTcfConsent(model);
expect(typeof tcString).toBe('string');
expect(tcString.length).toBeGreaterThan(0);
// Should be decodable
const decoded = decodeTCString(tcString);
expect(decoded.purposeConsents).toEqual(new Set([1, 2, 3]));
});
it('updates getTcString return value', () => {
installTcfApi(42, 1);
expect(getTcString()).toBe('');
const model = createTCModel({ created: 100000, lastUpdated: 100000 });
const tcString = updateTcfConsent(model);
expect(getTcString()).toBe(tcString);
});
it('works without installTcfApi (returns TC string, no listeners)', () => {
// apiState is null, but should still encode
const model = createTCModel({ created: 100000, lastUpdated: 100000 });
const tcString = updateTcfConsent(model);
expect(typeof tcString).toBe('string');
expect(tcString.length).toBeGreaterThan(0);
});
});
// ── setTcfDisplayStatus ──────────────────────────────────────────────
describe('setTcfDisplayStatus', () => {
beforeEach(() => {
removeTcfApi();
});
afterEach(() => {
removeTcfApi();
});
it('updates the display status returned by ping', () => {
installTcfApi(42, 1);
setTcfDisplayStatus('visible');
const callback = vi.fn();
const api = window.__tcfapi as Function;
api('ping', 2, callback);
expect(callback.mock.calls[0][0].displayStatus).toBe('visible');
});
it('does nothing when API not installed', () => {
// Should not throw
expect(() => setTcfDisplayStatus('visible')).not.toThrow();
});
});
// ── removeTcfApi ─────────────────────────────────────────────────────
describe('removeTcfApi', () => {
it('removes __tcfapi from window', () => {
installTcfApi(42, 1);
expect(window.__tcfapi).toBeDefined();
removeTcfApi();
expect(window.__tcfapi).toBeUndefined();
});
it('cleans up __tcfapiQueue', () => {
window.__tcfapiQueue = [];
installTcfApi(42, 1);
removeTcfApi();
expect(window.__tcfapiQueue).toBeUndefined();
});
it('is safe to call multiple times', () => {
expect(() => {
removeTcfApi();
removeTcfApi();
}).not.toThrow();
});
});