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(); }); });