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:
248
apps/banner/src/__tests__/a11y.test.ts
Normal file
248
apps/banner/src/__tests__/a11y.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
368
apps/banner/src/__tests__/banner.test.ts
Normal file
368
apps/banner/src/__tests__/banner.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
333
apps/banner/src/__tests__/blocker.test.ts
Normal file
333
apps/banner/src/__tests__/blocker.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
apps/banner/src/__tests__/consent.test.ts
Normal file
104
apps/banner/src/__tests__/consent.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
155
apps/banner/src/__tests__/gcm.test.ts
Normal file
155
apps/banner/src/__tests__/gcm.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
226
apps/banner/src/__tests__/gpc.test.ts
Normal file
226
apps/banner/src/__tests__/gpc.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
501
apps/banner/src/__tests__/gpp-api.test.ts
Normal file
501
apps/banner/src/__tests__/gpp-api.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
926
apps/banner/src/__tests__/gpp.test.ts
Normal file
926
apps/banner/src/__tests__/gpp.test.ts
Normal 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 (1–20)', () => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
190
apps/banner/src/__tests__/i18n.test.ts
Normal file
190
apps/banner/src/__tests__/i18n.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
157
apps/banner/src/__tests__/loader.test.ts
Normal file
157
apps/banner/src/__tests__/loader.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
212
apps/banner/src/__tests__/reconsent.test.ts
Normal file
212
apps/banner/src/__tests__/reconsent.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
493
apps/banner/src/__tests__/reporter.test.ts
Normal file
493
apps/banner/src/__tests__/reporter.test.ts
Normal 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=/';
|
||||
});
|
||||
});
|
||||
112
apps/banner/src/__tests__/shopify.test.ts
Normal file
112
apps/banner/src/__tests__/shopify.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
856
apps/banner/src/__tests__/tcf.test.ts
Normal file
856
apps/banner/src/__tests__/tcf.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user