@consentify/core 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,43 +1,15 @@
1
- /**
2
- * Literal type for the non-optional category that is always enabled.
3
- */
4
1
  export type Necessary = 'necessary';
5
- /**
6
- * User-defined category identifier (e.g., 'analytics', 'marketing').
7
- */
8
2
  export type UserCategory = string;
9
- /**
10
- * Map of consent choices for all categories, including the 'necessary' category.
11
- * A value of `true` means the user granted consent for the category.
12
- */
13
3
  export type Choices<T extends UserCategory> = Record<Necessary | T, boolean>;
14
- /**
15
- * Describes a cookie policy and its consent categories.
16
- * @template T Category string union used by this policy.
17
- */
18
4
  export interface Policy<T extends UserCategory> {
19
- /**
20
- * Optional stable identifier for your policy. Prefer supplying a value derived from
21
- * your actual policy content/version (e.g., a hash of the policy document).
22
- * If omitted, a deterministic hash of the provided categories (and this identifier when present)
23
- * will be used to key snapshots.
24
- */
25
5
  identifier?: string;
26
6
  categories: readonly T[];
27
7
  }
28
- /**
29
- * Immutable snapshot of a user's consent decision for a specific policy version.
30
- * @template T Category string union captured in the snapshot.
31
- */
32
8
  export interface Snapshot<T extends UserCategory> {
33
9
  policy: string;
34
10
  givenAt: string;
35
11
  choices: Choices<T>;
36
12
  }
37
- /**
38
- * High-level consent state derived from the presence of a valid snapshot.
39
- * When no valid snapshot exists for the current policy version, the state is `unset`.
40
- */
41
13
  export type ConsentState<T extends UserCategory> = {
42
14
  decision: 'unset';
43
15
  } | {
@@ -59,18 +31,14 @@ export interface CreateConsentifyInit<Cs extends readonly string[]> {
59
31
  path?: string;
60
32
  domain?: string;
61
33
  };
62
- /**
63
- * Maximum age of consent in days. If set, consent older than this
64
- * will be treated as expired, requiring re-consent.
65
- */
66
34
  consentMaxAgeDays?: number;
67
- /**
68
- * Client-side storage priority. Server-side access is cookie-only.
69
- * Supported: 'cookie' (canonical), 'localStorage' (optional mirror for fast reads)
70
- * Default: ['cookie']
71
- */
72
35
  storage?: StorageKind[];
73
36
  }
37
+ export interface ConsentifySubscribable<T extends UserCategory> {
38
+ subscribe: (callback: () => void) => () => void;
39
+ get: () => ConsentState<T>;
40
+ getServerSnapshot: () => ConsentState<T>;
41
+ }
74
42
  export declare function createConsentify<Cs extends readonly string[]>(init: CreateConsentifyInit<Cs>): {
75
43
  readonly policy: {
76
44
  readonly categories: Cs;
@@ -84,14 +52,50 @@ export declare function createConsentify<Cs extends readonly string[]>(init: Cre
84
52
  readonly client: {
85
53
  get: {
86
54
  (): ConsentState<ArrToUnion<Cs>>;
87
- (category: "necessary" | ArrToUnion<Cs>): boolean;
55
+ (category: Necessary | ArrToUnion<Cs>): boolean;
88
56
  };
89
57
  set: (choices: Partial<Choices<ArrToUnion<Cs>>>) => void;
90
58
  clear: () => void;
91
59
  subscribe: (callback: () => void) => (() => void);
92
60
  getServerSnapshot: () => ConsentState<ArrToUnion<Cs>>;
61
+ guard: (category: Necessary | ArrToUnion<Cs>, onGrant: () => void, onRevoke?: () => void) => (() => void);
62
+ };
63
+ readonly get: {
64
+ (): ConsentState<ArrToUnion<Cs>>;
65
+ (cookieHeader: string): ConsentState<ArrToUnion<Cs>>;
66
+ (cookieHeader: null): ConsentState<ArrToUnion<Cs>>;
67
+ };
68
+ readonly isGranted: (category: Necessary | ArrToUnion<Cs>) => boolean;
69
+ readonly set: {
70
+ (choices: Partial<Choices<ArrToUnion<Cs>>>): void;
71
+ (choices: Partial<Choices<ArrToUnion<Cs>>>, cookieHeader: string): string;
72
+ };
73
+ readonly clear: {
74
+ (): void;
75
+ (serverMode: string): string;
93
76
  };
77
+ readonly subscribe: (callback: () => void) => (() => void);
78
+ readonly getServerSnapshot: () => ConsentState<ArrToUnion<Cs>>;
79
+ readonly guard: (category: Necessary | ArrToUnion<Cs>, onGrant: () => void, onRevoke?: () => void) => (() => void);
94
80
  };
95
81
  export declare const defaultCategories: readonly ["preferences", "analytics", "marketing", "functional", "unclassified"];
96
82
  export type DefaultCategory = typeof defaultCategories[number];
83
+ export type GoogleConsentType = 'ad_storage' | 'ad_user_data' | 'ad_personalization' | 'analytics_storage' | 'functionality_storage' | 'personalization_storage' | 'security_storage';
84
+ export interface ConsentModeOptions<T extends string> {
85
+ mapping: Partial<Record<'necessary' | T, GoogleConsentType[]>>;
86
+ waitForUpdate?: number;
87
+ }
88
+ export declare const defaultConsentModeMapping: {
89
+ readonly necessary: readonly ["security_storage"];
90
+ readonly analytics: readonly ["analytics_storage"];
91
+ readonly marketing: readonly ["ad_storage", "ad_user_data", "ad_personalization"];
92
+ readonly preferences: readonly ["functionality_storage", "personalization_storage"];
93
+ };
94
+ declare global {
95
+ interface Window {
96
+ dataLayer: unknown[];
97
+ gtag: (...args: unknown[]) => void;
98
+ }
99
+ }
100
+ export declare function enableConsentMode<T extends string>(instance: ConsentifySubscribable<T>, options: ConsentModeOptions<T>): () => void;
97
101
  export {};
package/dist/index.js CHANGED
@@ -15,11 +15,8 @@ function fnv1a(str) {
15
15
  return ('00000000' + h.toString(16)).slice(-8);
16
16
  }
17
17
  function hashPolicy(categories, identifier) {
18
- // Deterministic identity for the policy. If you provide `identifier`, it is folded into the hash,
19
- // but consider using `identifier` itself as the canonical version key for clarity.
20
18
  return fnv1a(stableStringify({ categories: [...categories].sort(), identifier: identifier ?? null }));
21
19
  }
22
- // --- Internals ---
23
20
  const DEFAULT_COOKIE = 'consentify';
24
21
  const enc = (o) => encodeURIComponent(JSON.stringify(o));
25
22
  const dec = (s) => { try {
@@ -30,11 +27,26 @@ catch {
30
27
  } };
31
28
  const toISO = () => new Date().toISOString();
32
29
  function isValidSnapshot(s) {
33
- return (typeof s === 'object' && s !== null &&
34
- typeof s.policy === 'string' &&
35
- typeof s.givenAt === 'string' &&
36
- typeof s.choices === 'object' &&
37
- s.choices !== null);
30
+ if (typeof s !== 'object' || s === null ||
31
+ typeof s.policy !== 'string' || s.policy === '' ||
32
+ typeof s.givenAt !== 'string' ||
33
+ typeof s.choices !== 'object' || s.choices === null)
34
+ return false;
35
+ if (isNaN(new Date(s.givenAt).getTime()))
36
+ return false;
37
+ for (const v of Object.values(s.choices)) {
38
+ if (typeof v !== 'boolean')
39
+ return false;
40
+ }
41
+ return true;
42
+ }
43
+ function buildSetCookieHeader(name, value, opt) {
44
+ let h = `${name}=${value}; Path=${opt.path}; Max-Age=${opt.maxAgeSec}; SameSite=${opt.sameSite}`;
45
+ if (opt.domain)
46
+ h += `; Domain=${opt.domain}`;
47
+ if (opt.secure)
48
+ h += `; Secure`;
49
+ return h;
38
50
  }
39
51
  function readCookie(name, cookieStr) {
40
52
  const src = cookieStr ?? (typeof document !== 'undefined' ? document.cookie : '');
@@ -46,14 +58,8 @@ function readCookie(name, cookieStr) {
46
58
  function writeCookie(name, value, opt) {
47
59
  if (typeof document === 'undefined')
48
60
  return;
49
- let c = `${name}=${value}; Path=${opt.path}; Max-Age=${opt.maxAgeSec}; SameSite=${opt.sameSite}`;
50
- if (opt.domain)
51
- c += `; Domain=${opt.domain}`;
52
- if (opt.secure)
53
- c += `; Secure`;
54
- document.cookie = c;
61
+ document.cookie = buildSetCookieHeader(name, value, opt);
55
62
  }
56
- // --- Unified Factory (single entry point) ---
57
63
  export function createConsentify(init) {
58
64
  const policyHash = init.policy.identifier ?? hashPolicy(init.policy.categories);
59
65
  const cookieName = init.cookie?.name ?? DEFAULT_COOKIE;
@@ -72,7 +78,7 @@ export function createConsentify(init) {
72
78
  return false;
73
79
  const givenTime = new Date(givenAt).getTime();
74
80
  if (isNaN(givenTime))
75
- return true; // Invalid date = expired
81
+ return true;
76
82
  const maxAgeMs = consentMaxAgeDays * 24 * 60 * 60 * 1000;
77
83
  return Date.now() - givenTime > maxAgeMs;
78
84
  };
@@ -90,7 +96,6 @@ export function createConsentify(init) {
90
96
  base.necessary = true;
91
97
  return base;
92
98
  };
93
- // --- client-side storage helpers ---
94
99
  const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined';
95
100
  const canLocal = () => { try {
96
101
  return isBrowser() && !!window.localStorage;
@@ -101,7 +106,13 @@ export function createConsentify(init) {
101
106
  const readFromStore = (kind) => {
102
107
  switch (kind) {
103
108
  case 'cookie': return readCookie(cookieName);
104
- case 'localStorage': return canLocal() ? window.localStorage.getItem(cookieName) : null;
109
+ case 'localStorage': try {
110
+ return canLocal() ? window.localStorage.getItem(cookieName) : null;
111
+ }
112
+ catch (err) {
113
+ console.warn('[consentify] localStorage read failed:', err);
114
+ return null;
115
+ }
105
116
  default: return null;
106
117
  }
107
118
  };
@@ -111,20 +122,31 @@ export function createConsentify(init) {
111
122
  writeCookie(cookieName, value, cookieCfg);
112
123
  break;
113
124
  case 'localStorage':
114
- if (canLocal())
115
- window.localStorage.setItem(cookieName, value);
125
+ try {
126
+ if (canLocal())
127
+ window.localStorage.setItem(cookieName, value);
128
+ }
129
+ catch (err) {
130
+ console.warn('[consentify] localStorage write failed:', err);
131
+ }
116
132
  break;
117
133
  }
118
134
  };
135
+ const clearCookieHeader = () => buildSetCookieHeader(cookieName, '', { ...cookieCfg, maxAgeSec: 0 });
119
136
  const clearStore = (kind) => {
120
137
  switch (kind) {
121
138
  case 'cookie':
122
139
  if (isBrowser())
123
- document.cookie = `${cookieName}=; Path=${cookieCfg.path}; Max-Age=0; SameSite=${cookieCfg.sameSite}${cookieCfg.domain ? `; Domain=${cookieCfg.domain}` : ''}${cookieCfg.secure ? '; Secure' : ''}`;
140
+ document.cookie = clearCookieHeader();
124
141
  break;
125
142
  case 'localStorage':
126
- if (canLocal())
127
- window.localStorage.removeItem(cookieName);
143
+ try {
144
+ if (canLocal())
145
+ window.localStorage.removeItem(cookieName);
146
+ }
147
+ catch (err) {
148
+ console.warn('[consentify] localStorage clear failed:', err);
149
+ }
128
150
  break;
129
151
  }
130
152
  };
@@ -151,7 +173,6 @@ export function createConsentify(init) {
151
173
  if (primary !== 'cookie' && storageOrder.includes('cookie'))
152
174
  writeToStore('cookie', value);
153
175
  };
154
- // --- read helpers ---
155
176
  const readClient = () => {
156
177
  const raw = readClientRaw();
157
178
  const s = raw ? dec(raw) : null;
@@ -170,15 +191,6 @@ export function createConsentify(init) {
170
191
  writeClientRaw(enc(next));
171
192
  return !same;
172
193
  };
173
- function buildSetCookieHeader(name, value, opt) {
174
- let header = `${name}=${value}; Path=${opt.path}; Max-Age=${opt.maxAgeSec}; SameSite=${opt.sameSite}`;
175
- if (opt.domain)
176
- header += `; Domain=${opt.domain}`;
177
- if (opt.secure)
178
- header += `; Secure`;
179
- return header;
180
- }
181
- // ---- server API
182
194
  const server = {
183
195
  get: (cookieHeader) => {
184
196
  const raw = cookieHeader ? readCookie(cookieName, cookieHeader) : null;
@@ -201,16 +213,8 @@ export function createConsentify(init) {
201
213
  };
202
214
  return buildSetCookieHeader(cookieName, enc(snapshot), cookieCfg);
203
215
  },
204
- clear: () => {
205
- let h = `${cookieName}=; Path=${cookieCfg.path}; Max-Age=0; SameSite=${cookieCfg.sameSite}`;
206
- if (cookieCfg.domain)
207
- h += `; Domain=${cookieCfg.domain}`;
208
- if (cookieCfg.secure)
209
- h += `; Secure`;
210
- return h;
211
- }
216
+ clear: () => clearCookieHeader()
212
217
  };
213
- // ========== NEW: Subscribe pattern for React ==========
214
218
  const listeners = new Set();
215
219
  const unsetState = { decision: 'unset' };
216
220
  let cachedState = unsetState;
@@ -224,14 +228,19 @@ export function createConsentify(init) {
224
228
  }
225
229
  };
226
230
  const notifyListeners = () => {
227
- listeners.forEach(cb => cb());
231
+ listeners.forEach(cb => {
232
+ try {
233
+ cb();
234
+ }
235
+ catch (err) {
236
+ console.error('[consentify] Listener callback threw:', err);
237
+ }
238
+ });
228
239
  };
229
- // Init cache on browser
230
240
  if (isBrowser()) {
231
241
  syncState();
232
242
  }
233
243
  function clientGet(category) {
234
- // Return cached state for React compatibility
235
244
  if (typeof category === 'undefined')
236
245
  return cachedState;
237
246
  if (category === 'necessary')
@@ -243,8 +252,8 @@ export function createConsentify(init) {
243
252
  const client = {
244
253
  get: clientGet,
245
254
  set: (choices) => {
246
- const prev = client.get();
247
- const base = prev.decision === 'decided' ? prev.snapshot.choices : normalize();
255
+ const fresh = readClient();
256
+ const base = fresh ? fresh.choices : normalize();
248
257
  const next = {
249
258
  policy: policyHash,
250
259
  givenAt: toISO(),
@@ -262,14 +271,47 @@ export function createConsentify(init) {
262
271
  syncState();
263
272
  notifyListeners();
264
273
  },
265
- // NEW: Subscribe for React useSyncExternalStore
266
274
  subscribe: (callback) => {
267
275
  listeners.add(callback);
268
276
  return () => listeners.delete(callback);
269
277
  },
270
- // NEW: Server snapshot for SSR (always unset)
271
278
  getServerSnapshot: () => unsetState,
279
+ guard: (category, onGrant, onRevoke) => {
280
+ let phase = 'waiting';
281
+ const check = () => clientGet(category) === true;
282
+ const tick = () => {
283
+ if (phase === 'waiting' && check()) {
284
+ onGrant();
285
+ phase = onRevoke ? 'granted' : 'done';
286
+ if (phase === 'done')
287
+ unsub();
288
+ }
289
+ else if (phase === 'granted' && !check()) {
290
+ onRevoke();
291
+ phase = 'done';
292
+ unsub();
293
+ }
294
+ };
295
+ const unsub = client.subscribe(tick);
296
+ tick();
297
+ return () => { phase = 'done'; unsub(); };
298
+ },
272
299
  };
300
+ function flatGet(cookieHeader) {
301
+ return typeof cookieHeader === 'string'
302
+ ? server.get(cookieHeader)
303
+ : client.get();
304
+ }
305
+ function flatSet(choices, cookieHeader) {
306
+ if (typeof cookieHeader === 'string')
307
+ return server.set(choices, cookieHeader);
308
+ client.set(choices);
309
+ }
310
+ function flatClear(serverMode) {
311
+ if (typeof serverMode === 'string')
312
+ return server.clear();
313
+ client.clear();
314
+ }
273
315
  return {
274
316
  policy: {
275
317
  categories: init.policy.categories,
@@ -277,7 +319,74 @@ export function createConsentify(init) {
277
319
  },
278
320
  server,
279
321
  client,
322
+ get: flatGet,
323
+ isGranted: (category) => {
324
+ return clientGet(category);
325
+ },
326
+ set: flatSet,
327
+ clear: flatClear,
328
+ subscribe: client.subscribe,
329
+ getServerSnapshot: client.getServerSnapshot,
330
+ guard: client.guard,
280
331
  };
281
332
  }
282
- // Common predefined category names you can reuse in your policy.
283
333
  export const defaultCategories = ['preferences', 'analytics', 'marketing', 'functional', 'unclassified'];
334
+ export const defaultConsentModeMapping = {
335
+ necessary: ['security_storage'],
336
+ analytics: ['analytics_storage'],
337
+ marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'],
338
+ preferences: ['functionality_storage', 'personalization_storage'],
339
+ };
340
+ function safeGtag(...args) {
341
+ try {
342
+ window.gtag(...args);
343
+ }
344
+ catch (err) {
345
+ console.error('[consentify] gtag call failed:', err);
346
+ }
347
+ }
348
+ export function enableConsentMode(instance, options) {
349
+ if (typeof window === 'undefined')
350
+ return () => { };
351
+ window.dataLayer = window.dataLayer || [];
352
+ if (typeof window.gtag !== 'function') {
353
+ window.gtag = function gtag() {
354
+ window.dataLayer.push(arguments);
355
+ };
356
+ }
357
+ const resolve = () => {
358
+ const state = instance.get();
359
+ const result = {};
360
+ for (const [category, gTypes] of Object.entries(options.mapping)) {
361
+ if (!gTypes)
362
+ continue;
363
+ let granted = false;
364
+ if (category === 'necessary') {
365
+ granted = true;
366
+ }
367
+ else if (state.decision === 'decided') {
368
+ granted = !!state.snapshot.choices[category];
369
+ }
370
+ for (const gType of gTypes) {
371
+ result[gType] = granted ? 'granted' : 'denied';
372
+ }
373
+ }
374
+ return result;
375
+ };
376
+ const defaultPayload = { ...resolve() };
377
+ if (options.waitForUpdate != null) {
378
+ defaultPayload.wait_for_update = options.waitForUpdate;
379
+ }
380
+ safeGtag('consent', 'default', defaultPayload);
381
+ const state = instance.get();
382
+ if (state.decision === 'decided') {
383
+ safeGtag('consent', 'update', resolve());
384
+ }
385
+ const unsubscribe = instance.subscribe(() => {
386
+ const current = instance.get();
387
+ if (current.decision === 'decided') {
388
+ safeGtag('consent', 'update', resolve());
389
+ }
390
+ });
391
+ return unsubscribe;
392
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,788 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { createConsentify, enableConsentMode } from './index';
3
+ function stableStringify(o) {
4
+ if (o === null || typeof o !== 'object')
5
+ return JSON.stringify(o);
6
+ if (Array.isArray(o))
7
+ return `[${o.map(stableStringify).join(',')}]`;
8
+ const e = Object.entries(o).sort((a, b) => a[0].localeCompare(b[0]));
9
+ return `{${e.map(([k, v]) => JSON.stringify(k) + ':' + stableStringify(v)).join(',')}}`;
10
+ }
11
+ function fnv1a(str) {
12
+ let h = 0x811c9dc5 >>> 0;
13
+ for (let i = 0; i < str.length; i++) {
14
+ h ^= str.charCodeAt(i);
15
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
16
+ }
17
+ return ('00000000' + h.toString(16)).slice(-8);
18
+ }
19
+ function hashPolicy(categories, identifier) {
20
+ return fnv1a(stableStringify({ categories: [...categories].sort(), identifier: identifier ?? null }));
21
+ }
22
+ const enc = (o) => encodeURIComponent(JSON.stringify(o));
23
+ function setCookie(name, value) {
24
+ document.cookie = `${name}=${value}; Path=/`;
25
+ }
26
+ function clearAllCookies() {
27
+ document.cookie.split(';').forEach(c => {
28
+ const name = c.split('=')[0].trim();
29
+ if (name)
30
+ document.cookie = `${name}=; Max-Age=0; Path=/`;
31
+ });
32
+ }
33
+ describe('stableStringify', () => {
34
+ it('produces deterministic output regardless of key order', () => {
35
+ expect(stableStringify({ b: 2, a: 1 })).toBe(stableStringify({ a: 1, b: 2 }));
36
+ });
37
+ it('handles nested objects', () => {
38
+ expect(stableStringify({ z: { b: 1, a: 2 } })).toBe('{"z":{"a":2,"b":1}}');
39
+ });
40
+ it('handles arrays', () => {
41
+ expect(stableStringify([3, 1, 2])).toBe('[3,1,2]');
42
+ });
43
+ it('handles null and primitives', () => {
44
+ expect(stableStringify(null)).toBe('null');
45
+ expect(stableStringify('hello')).toBe('"hello"');
46
+ expect(stableStringify(42)).toBe('42');
47
+ });
48
+ });
49
+ describe('fnv1a', () => {
50
+ it('returns consistent 8-char hex string', () => {
51
+ const h = fnv1a('test');
52
+ expect(h).toMatch(/^[0-9a-f]{8}$/);
53
+ expect(fnv1a('test')).toBe(h);
54
+ });
55
+ it('produces different hashes for different inputs', () => {
56
+ expect(fnv1a('abc')).not.toBe(fnv1a('def'));
57
+ });
58
+ });
59
+ describe('hashPolicy', () => {
60
+ it('is stable across category order', () => {
61
+ expect(hashPolicy(['a', 'b'])).toBe(hashPolicy(['b', 'a']));
62
+ });
63
+ it('changes when categories change', () => {
64
+ expect(hashPolicy(['a'])).not.toBe(hashPolicy(['a', 'b']));
65
+ });
66
+ it('folds identifier into hash', () => {
67
+ expect(hashPolicy(['a'], 'v1')).not.toBe(hashPolicy(['a']));
68
+ });
69
+ });
70
+ describe('readCookie (via server.get)', () => {
71
+ it('returns unset when no cookie', () => {
72
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
73
+ expect(c.server.get('')).toEqual({ decision: 'unset' });
74
+ });
75
+ it('returns unset for null/undefined', () => {
76
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
77
+ expect(c.server.get(null)).toEqual({ decision: 'unset' });
78
+ expect(c.server.get(undefined)).toEqual({ decision: 'unset' });
79
+ });
80
+ it('parses cookie among multiple cookies', () => {
81
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
82
+ const snapshot = {
83
+ policy: c.policy.identifier,
84
+ givenAt: new Date().toISOString(),
85
+ choices: { necessary: true, analytics: true },
86
+ };
87
+ const header = `other=foo; consentify=${enc(snapshot)}; another=bar`;
88
+ const state = c.server.get(header);
89
+ expect(state.decision).toBe('decided');
90
+ });
91
+ });
92
+ describe('writeCookie (via client)', () => {
93
+ beforeEach(clearAllCookies);
94
+ it('writes to document.cookie via client.set()', () => {
95
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
96
+ c.client.set({ analytics: true });
97
+ expect(document.cookie).toContain('consentify=');
98
+ });
99
+ });
100
+ describe('isValidSnapshot (via server.get)', () => {
101
+ const makeInstance = () => createConsentify({ policy: { categories: ['analytics'] } });
102
+ it('accepts a valid snapshot', () => {
103
+ const c = makeInstance();
104
+ const snapshot = {
105
+ policy: c.policy.identifier,
106
+ givenAt: new Date().toISOString(),
107
+ choices: { necessary: true, analytics: false },
108
+ };
109
+ const header = `consentify=${enc(snapshot)}`;
110
+ expect(c.server.get(header).decision).toBe('decided');
111
+ });
112
+ it('rejects missing fields', () => {
113
+ const c = makeInstance();
114
+ const bad = { policy: c.policy.identifier, choices: { necessary: true, analytics: false } };
115
+ expect(c.server.get(`consentify=${enc(bad)}`).decision).toBe('unset');
116
+ });
117
+ it('rejects non-boolean choices', () => {
118
+ const c = makeInstance();
119
+ const bad = {
120
+ policy: c.policy.identifier,
121
+ givenAt: new Date().toISOString(),
122
+ choices: { necessary: true, analytics: 'yes' },
123
+ };
124
+ expect(c.server.get(`consentify=${enc(bad)}`).decision).toBe('unset');
125
+ });
126
+ it('rejects invalid dates', () => {
127
+ const c = makeInstance();
128
+ const bad = {
129
+ policy: c.policy.identifier,
130
+ givenAt: 'not-a-date',
131
+ choices: { necessary: true, analytics: false },
132
+ };
133
+ expect(c.server.get(`consentify=${enc(bad)}`).decision).toBe('unset');
134
+ });
135
+ it('rejects empty policy string', () => {
136
+ const c = makeInstance();
137
+ const bad = {
138
+ policy: '',
139
+ givenAt: new Date().toISOString(),
140
+ choices: { necessary: true, analytics: false },
141
+ };
142
+ expect(c.server.get(`consentify=${enc(bad)}`).decision).toBe('unset');
143
+ });
144
+ });
145
+ describe('server API', () => {
146
+ it('get() returns unset when no cookie', () => {
147
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
148
+ expect(c.server.get('')).toEqual({ decision: 'unset' });
149
+ });
150
+ it('get() returns decided with valid cookie', () => {
151
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
152
+ const snapshot = {
153
+ policy: c.policy.identifier,
154
+ givenAt: new Date().toISOString(),
155
+ choices: { necessary: true, analytics: true },
156
+ };
157
+ const state = c.server.get(`consentify=${enc(snapshot)}`);
158
+ expect(state.decision).toBe('decided');
159
+ if (state.decision === 'decided') {
160
+ expect(state.snapshot.choices.analytics).toBe(true);
161
+ }
162
+ });
163
+ it('get() returns unset on policy mismatch', () => {
164
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
165
+ const snapshot = {
166
+ policy: 'wrong-hash',
167
+ givenAt: new Date().toISOString(),
168
+ choices: { necessary: true, analytics: true },
169
+ };
170
+ expect(c.server.get(`consentify=${enc(snapshot)}`).decision).toBe('unset');
171
+ });
172
+ it('get() returns unset on expired consent', () => {
173
+ const c = createConsentify({
174
+ policy: { categories: ['analytics'] },
175
+ consentMaxAgeDays: 1,
176
+ });
177
+ const oldDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
178
+ const snapshot = {
179
+ policy: c.policy.identifier,
180
+ givenAt: oldDate,
181
+ choices: { necessary: true, analytics: true },
182
+ };
183
+ expect(c.server.get(`consentify=${enc(snapshot)}`).decision).toBe('unset');
184
+ });
185
+ it('set() returns a Set-Cookie header string', () => {
186
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
187
+ const header = c.server.set({ analytics: true });
188
+ expect(header).toContain('consentify=');
189
+ expect(header).toContain('Path=/');
190
+ expect(header).toContain('SameSite=Lax');
191
+ });
192
+ it('clear() returns a clearing header with Max-Age=0', () => {
193
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
194
+ const header = c.server.clear();
195
+ expect(header).toContain('Max-Age=0');
196
+ expect(header).toContain('consentify=;');
197
+ });
198
+ it('necessary is always true in server.set()', () => {
199
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
200
+ const header = c.server.set({ necessary: false });
201
+ const val = header.split(';')[0].split('=').slice(1).join('=');
202
+ const snapshot = JSON.parse(decodeURIComponent(val));
203
+ expect(snapshot.choices.necessary).toBe(true);
204
+ });
205
+ });
206
+ describe('client API', () => {
207
+ beforeEach(clearAllCookies);
208
+ it('get() returns unset initially', () => {
209
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
210
+ expect(c.client.get()).toEqual({ decision: 'unset' });
211
+ });
212
+ it('get(category) returns boolean', () => {
213
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
214
+ expect(c.client.get('necessary')).toBe(true);
215
+ expect(c.client.get('analytics')).toBe(false);
216
+ });
217
+ it('set() stores and reads back', () => {
218
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
219
+ c.client.set({ analytics: true });
220
+ const state = c.client.get();
221
+ expect(state.decision).toBe('decided');
222
+ if (state.decision === 'decided') {
223
+ expect(state.snapshot.choices.analytics).toBe(true);
224
+ expect(state.snapshot.choices.necessary).toBe(true);
225
+ }
226
+ });
227
+ it('set() race condition: sequential sets preserve both', () => {
228
+ const c = createConsentify({ policy: { categories: ['analytics', 'marketing'] } });
229
+ c.client.set({ analytics: true });
230
+ c.client.set({ marketing: true });
231
+ const state = c.client.get();
232
+ expect(state.decision).toBe('decided');
233
+ if (state.decision === 'decided') {
234
+ expect(state.snapshot.choices.analytics).toBe(true);
235
+ expect(state.snapshot.choices.marketing).toBe(true);
236
+ }
237
+ });
238
+ it('clear() resets to unset', () => {
239
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
240
+ c.client.set({ analytics: true });
241
+ expect(c.client.get().decision).toBe('decided');
242
+ c.client.clear();
243
+ expect(c.client.get()).toEqual({ decision: 'unset' });
244
+ });
245
+ it('subscribe() callback fired on set and clear', () => {
246
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
247
+ const cb = vi.fn();
248
+ const unsub = c.client.subscribe(cb);
249
+ c.client.set({ analytics: true });
250
+ expect(cb).toHaveBeenCalledTimes(1);
251
+ c.client.clear();
252
+ expect(cb).toHaveBeenCalledTimes(2);
253
+ unsub();
254
+ c.client.set({ analytics: false });
255
+ expect(cb).toHaveBeenCalledTimes(2);
256
+ });
257
+ it('subscribe() one error does not break other listeners', () => {
258
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
259
+ const bad = vi.fn(() => { throw new Error('boom'); });
260
+ const good = vi.fn();
261
+ c.client.subscribe(bad);
262
+ c.client.subscribe(good);
263
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
264
+ c.client.set({ analytics: true });
265
+ expect(bad).toHaveBeenCalled();
266
+ expect(good).toHaveBeenCalled();
267
+ spy.mockRestore();
268
+ });
269
+ it('subscribe() error is logged via console.error', () => {
270
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
271
+ const err = new Error('boom');
272
+ c.client.subscribe(() => { throw err; });
273
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
274
+ c.client.set({ analytics: true });
275
+ expect(spy).toHaveBeenCalledWith('[consentify] Listener callback threw:', err);
276
+ spy.mockRestore();
277
+ });
278
+ it('getServerSnapshot() always returns unset', () => {
279
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
280
+ c.client.set({ analytics: true });
281
+ expect(c.client.getServerSnapshot()).toEqual({ decision: 'unset' });
282
+ });
283
+ });
284
+ describe('storage fallback', () => {
285
+ beforeEach(clearAllCookies);
286
+ it('localStorage primary with cookie mirror', () => {
287
+ const c = createConsentify({
288
+ policy: { categories: ['analytics'] },
289
+ storage: ['localStorage', 'cookie'],
290
+ });
291
+ c.client.set({ analytics: true });
292
+ expect(window.localStorage.getItem('consentify')).toBeTruthy();
293
+ expect(document.cookie).toContain('consentify=');
294
+ });
295
+ it('localStorage failure falls back gracefully', () => {
296
+ const orig = window.localStorage.setItem;
297
+ window.localStorage.setItem = () => { throw new DOMException('QuotaExceeded'); };
298
+ const c = createConsentify({
299
+ policy: { categories: ['analytics'] },
300
+ storage: ['localStorage', 'cookie'],
301
+ });
302
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => { });
303
+ expect(() => c.client.set({ analytics: true })).not.toThrow();
304
+ expect(c.client.get('analytics')).toBe(true);
305
+ spy.mockRestore();
306
+ window.localStorage.setItem = orig;
307
+ });
308
+ });
309
+ describe('policy versioning', () => {
310
+ beforeEach(clearAllCookies);
311
+ it('changed categories invalidate consent', () => {
312
+ const c1 = createConsentify({ policy: { categories: ['analytics'] } });
313
+ c1.client.set({ analytics: true });
314
+ const c2 = createConsentify({ policy: { categories: ['analytics', 'marketing'] } });
315
+ expect(c2.client.get()).toEqual({ decision: 'unset' });
316
+ });
317
+ it('custom identifier works', () => {
318
+ const c = createConsentify({
319
+ policy: { categories: ['analytics'], identifier: 'v2' },
320
+ });
321
+ expect(c.policy.identifier).toBe('v2');
322
+ });
323
+ });
324
+ describe('consent expiration', () => {
325
+ beforeEach(clearAllCookies);
326
+ it('fresh consent is valid', () => {
327
+ const c = createConsentify({
328
+ policy: { categories: ['analytics'] },
329
+ consentMaxAgeDays: 365,
330
+ });
331
+ const snapshot = {
332
+ policy: c.policy.identifier,
333
+ givenAt: new Date().toISOString(),
334
+ choices: { necessary: true, analytics: true },
335
+ };
336
+ expect(c.server.get(`consentify=${enc(snapshot)}`).decision).toBe('decided');
337
+ });
338
+ it('old consent is expired', () => {
339
+ const c = createConsentify({
340
+ policy: { categories: ['analytics'] },
341
+ consentMaxAgeDays: 30,
342
+ });
343
+ const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString();
344
+ const snapshot = {
345
+ policy: c.policy.identifier,
346
+ givenAt: oldDate,
347
+ choices: { necessary: true, analytics: true },
348
+ };
349
+ expect(c.server.get(`consentify=${enc(snapshot)}`).decision).toBe('unset');
350
+ });
351
+ it('invalid date treated as expired', () => {
352
+ const c = createConsentify({
353
+ policy: { categories: ['analytics'] },
354
+ consentMaxAgeDays: 365,
355
+ });
356
+ const snapshot = {
357
+ policy: c.policy.identifier,
358
+ givenAt: 'invalid-date',
359
+ choices: { necessary: true, analytics: true },
360
+ };
361
+ expect(c.server.get(`consentify=${enc(snapshot)}`).decision).toBe('unset');
362
+ });
363
+ });
364
+ describe('client.guard()', () => {
365
+ beforeEach(clearAllCookies);
366
+ it('fires immediately when already consented', () => {
367
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
368
+ c.client.set({ analytics: true });
369
+ const onGrant = vi.fn();
370
+ c.client.guard('analytics', onGrant);
371
+ expect(onGrant).toHaveBeenCalledTimes(1);
372
+ });
373
+ it('defers until consent is granted', () => {
374
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
375
+ const onGrant = vi.fn();
376
+ c.client.guard('analytics', onGrant);
377
+ expect(onGrant).not.toHaveBeenCalled();
378
+ c.client.set({ analytics: true });
379
+ expect(onGrant).toHaveBeenCalledTimes(1);
380
+ });
381
+ it('onRevoke fires when consent is withdrawn', () => {
382
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
383
+ const onGrant = vi.fn();
384
+ const onRevoke = vi.fn();
385
+ c.client.guard('analytics', onGrant, onRevoke);
386
+ c.client.set({ analytics: true });
387
+ expect(onGrant).toHaveBeenCalledTimes(1);
388
+ c.client.set({ analytics: false });
389
+ expect(onRevoke).toHaveBeenCalledTimes(1);
390
+ });
391
+ it('does not fire onGrant again after revoke', () => {
392
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
393
+ const onGrant = vi.fn();
394
+ const onRevoke = vi.fn();
395
+ c.client.guard('analytics', onGrant, onRevoke);
396
+ c.client.set({ analytics: true });
397
+ c.client.set({ analytics: false });
398
+ c.client.set({ analytics: true });
399
+ expect(onGrant).toHaveBeenCalledTimes(1);
400
+ expect(onRevoke).toHaveBeenCalledTimes(1);
401
+ });
402
+ it('dispose cancels before grant', () => {
403
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
404
+ const onGrant = vi.fn();
405
+ const dispose = c.client.guard('analytics', onGrant);
406
+ dispose();
407
+ c.client.set({ analytics: true });
408
+ expect(onGrant).not.toHaveBeenCalled();
409
+ });
410
+ it('dispose cancels before revoke', () => {
411
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
412
+ const onGrant = vi.fn();
413
+ const onRevoke = vi.fn();
414
+ c.client.guard('analytics', onGrant, onRevoke);
415
+ c.client.set({ analytics: true });
416
+ const dispose = c.client.guard('analytics', vi.fn(), onRevoke);
417
+ dispose();
418
+ c.client.set({ analytics: false });
419
+ expect(onRevoke).toHaveBeenCalledTimes(1);
420
+ });
421
+ it('guard("necessary") fires immediately (always true)', () => {
422
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
423
+ const onGrant = vi.fn();
424
+ c.client.guard('necessary', onGrant);
425
+ expect(onGrant).toHaveBeenCalledTimes(1);
426
+ });
427
+ it('without onRevoke stops watching after grant', () => {
428
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
429
+ const onGrant = vi.fn();
430
+ c.client.guard('analytics', onGrant);
431
+ c.client.set({ analytics: true });
432
+ expect(onGrant).toHaveBeenCalledTimes(1);
433
+ c.client.set({ analytics: false });
434
+ c.client.set({ analytics: true });
435
+ expect(onGrant).toHaveBeenCalledTimes(1);
436
+ });
437
+ });
438
+ describe('unified top-level API', () => {
439
+ beforeEach(clearAllCookies);
440
+ it('get() delegates to client.get()', () => {
441
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
442
+ expect(c.get()).toEqual({ decision: 'unset' });
443
+ c.client.set({ analytics: true });
444
+ expect(c.get().decision).toBe('decided');
445
+ });
446
+ it('get(cookieHeader) delegates to server.get()', () => {
447
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
448
+ const snapshot = {
449
+ policy: c.policy.identifier,
450
+ givenAt: new Date().toISOString(),
451
+ choices: { necessary: true, analytics: true },
452
+ };
453
+ const header = `consentify=${enc(snapshot)}`;
454
+ const state = c.get(header);
455
+ expect(state.decision).toBe('decided');
456
+ });
457
+ it('get(null) falls through to client.get()', () => {
458
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
459
+ expect(c.get(null)).toEqual({ decision: 'unset' });
460
+ });
461
+ it('get("") delegates to server.get() and returns unset', () => {
462
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
463
+ expect(c.get('')).toEqual({ decision: 'unset' });
464
+ });
465
+ it('isGranted("analytics") returns correct boolean', () => {
466
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
467
+ expect(c.isGranted('analytics')).toBe(false);
468
+ c.client.set({ analytics: true });
469
+ expect(c.isGranted('analytics')).toBe(true);
470
+ });
471
+ it('isGranted("necessary") always returns true', () => {
472
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
473
+ expect(c.isGranted('necessary')).toBe(true);
474
+ });
475
+ it('set(choices) delegates to client.set()', () => {
476
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
477
+ c.set({ analytics: true });
478
+ expect(c.client.get('analytics')).toBe(true);
479
+ });
480
+ it('set(choices, cookieHeader) returns Set-Cookie string', () => {
481
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
482
+ const result = c.set({ analytics: true }, '');
483
+ expect(typeof result).toBe('string');
484
+ expect(result).toContain('consentify=');
485
+ });
486
+ it('clear() delegates to client.clear()', () => {
487
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
488
+ c.client.set({ analytics: true });
489
+ expect(c.get().decision).toBe('decided');
490
+ c.clear();
491
+ expect(c.get()).toEqual({ decision: 'unset' });
492
+ });
493
+ it('clear(cookieHeader) returns clearing header', () => {
494
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
495
+ const result = c.clear('somecookie=value');
496
+ expect(typeof result).toBe('string');
497
+ expect(result).toContain('Max-Age=0');
498
+ });
499
+ it('subscribe(cb) works at top level', () => {
500
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
501
+ const cb = vi.fn();
502
+ const unsub = c.subscribe(cb);
503
+ c.set({ analytics: true });
504
+ expect(cb).toHaveBeenCalledTimes(1);
505
+ unsub();
506
+ c.set({ analytics: false });
507
+ expect(cb).toHaveBeenCalledTimes(1);
508
+ });
509
+ it('guard() works at top level', () => {
510
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
511
+ const onGrant = vi.fn();
512
+ c.guard('analytics', onGrant);
513
+ expect(onGrant).not.toHaveBeenCalled();
514
+ c.set({ analytics: true });
515
+ expect(onGrant).toHaveBeenCalledTimes(1);
516
+ });
517
+ it('getServerSnapshot() returns unset', () => {
518
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
519
+ expect(c.getServerSnapshot()).toEqual({ decision: 'unset' });
520
+ });
521
+ });
522
+ function findGtagCall(action, type) {
523
+ for (const entry of window.dataLayer) {
524
+ const args = Array.from(entry);
525
+ if (args[0] === action && args[1] === type) {
526
+ return args[2];
527
+ }
528
+ }
529
+ return undefined;
530
+ }
531
+ function countGtagCalls(action, type) {
532
+ let count = 0;
533
+ for (const entry of window.dataLayer) {
534
+ const args = Array.from(entry);
535
+ if (args[0] === action && args[1] === type)
536
+ count++;
537
+ }
538
+ return count;
539
+ }
540
+ describe('enableConsentMode', () => {
541
+ let consent;
542
+ beforeEach(() => {
543
+ delete window.dataLayer;
544
+ delete window.gtag;
545
+ clearAllCookies();
546
+ localStorage.clear();
547
+ consent = createConsentify({
548
+ policy: { categories: ['analytics', 'marketing', 'preferences'] },
549
+ });
550
+ });
551
+ it('returns no-op dispose and makes no gtag calls in SSR', () => {
552
+ const origWindow = globalThis.window;
553
+ Object.defineProperty(globalThis, 'window', { value: undefined, configurable: true });
554
+ const dispose = enableConsentMode(consent, {
555
+ mapping: { analytics: ['analytics_storage'] },
556
+ });
557
+ expect(dispose).toBeTypeOf('function');
558
+ dispose();
559
+ Object.defineProperty(globalThis, 'window', { value: origWindow, configurable: true });
560
+ });
561
+ it('bootstraps dataLayer and gtag if missing', () => {
562
+ expect(window.dataLayer).toBeUndefined();
563
+ expect(window.gtag).toBeUndefined();
564
+ enableConsentMode(consent, {
565
+ mapping: { analytics: ['analytics_storage'] },
566
+ });
567
+ expect(Array.isArray(window.dataLayer)).toBe(true);
568
+ expect(typeof window.gtag).toBe('function');
569
+ });
570
+ it('preserves existing dataLayer and gtag', () => {
571
+ const existingData = [{ event: 'existing' }];
572
+ window.dataLayer = existingData;
573
+ const customGtag = vi.fn(function gtag() { window.dataLayer.push(arguments); });
574
+ window.gtag = customGtag;
575
+ enableConsentMode(consent, {
576
+ mapping: { analytics: ['analytics_storage'] },
577
+ });
578
+ expect(window.dataLayer[0]).toEqual({ event: 'existing' });
579
+ expect(customGtag).toHaveBeenCalled();
580
+ });
581
+ it('calls gtag consent default on init with mapped types as denied', () => {
582
+ enableConsentMode(consent, {
583
+ mapping: {
584
+ analytics: ['analytics_storage'],
585
+ marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'],
586
+ },
587
+ });
588
+ const defaultCall = findGtagCall('consent', 'default');
589
+ expect(defaultCall).toBeDefined();
590
+ expect(defaultCall.analytics_storage).toBe('denied');
591
+ expect(defaultCall.ad_storage).toBe('denied');
592
+ expect(defaultCall.ad_user_data).toBe('denied');
593
+ expect(defaultCall.ad_personalization).toBe('denied');
594
+ });
595
+ it('passes wait_for_update in default call when provided', () => {
596
+ enableConsentMode(consent, {
597
+ mapping: { analytics: ['analytics_storage'] },
598
+ waitForUpdate: 500,
599
+ });
600
+ const defaultCall = findGtagCall('consent', 'default');
601
+ expect(defaultCall).toBeDefined();
602
+ expect(defaultCall.wait_for_update).toBe(500);
603
+ });
604
+ it('does not include wait_for_update when not provided', () => {
605
+ enableConsentMode(consent, {
606
+ mapping: { analytics: ['analytics_storage'] },
607
+ });
608
+ const defaultCall = findGtagCall('consent', 'default');
609
+ expect(defaultCall).toBeDefined();
610
+ expect(defaultCall).not.toHaveProperty('wait_for_update');
611
+ });
612
+ it('calls both default and update if consent already decided', () => {
613
+ consent.set({ analytics: true, marketing: false });
614
+ enableConsentMode(consent, {
615
+ mapping: {
616
+ analytics: ['analytics_storage'],
617
+ marketing: ['ad_storage'],
618
+ },
619
+ });
620
+ expect(countGtagCalls('consent', 'default')).toBe(1);
621
+ expect(countGtagCalls('consent', 'update')).toBe(1);
622
+ const updateCall = findGtagCall('consent', 'update');
623
+ expect(updateCall.analytics_storage).toBe('granted');
624
+ expect(updateCall.ad_storage).toBe('denied');
625
+ });
626
+ it('only calls default if consent is unset', () => {
627
+ enableConsentMode(consent, {
628
+ mapping: { analytics: ['analytics_storage'] },
629
+ });
630
+ expect(countGtagCalls('consent', 'default')).toBe(1);
631
+ expect(countGtagCalls('consent', 'update')).toBe(0);
632
+ });
633
+ it('calls gtag consent update on set()', () => {
634
+ enableConsentMode(consent, {
635
+ mapping: {
636
+ analytics: ['analytics_storage'],
637
+ marketing: ['ad_storage', 'ad_user_data'],
638
+ },
639
+ });
640
+ consent.set({ analytics: true, marketing: false });
641
+ const updateCalls = window.dataLayer.filter(entry => {
642
+ const args = Array.from(entry);
643
+ return args[0] === 'consent' && args[1] === 'update';
644
+ });
645
+ expect(updateCalls.length).toBeGreaterThanOrEqual(1);
646
+ const lastUpdate = Array.from(updateCalls[updateCalls.length - 1]);
647
+ const payload = lastUpdate[2];
648
+ expect(payload.analytics_storage).toBe('granted');
649
+ expect(payload.ad_storage).toBe('denied');
650
+ expect(payload.ad_user_data).toBe('denied');
651
+ });
652
+ it('maps multiple categories correctly', () => {
653
+ enableConsentMode(consent, {
654
+ mapping: {
655
+ analytics: ['analytics_storage'],
656
+ marketing: ['ad_storage'],
657
+ preferences: ['functionality_storage', 'personalization_storage'],
658
+ },
659
+ });
660
+ consent.set({ analytics: true, marketing: false, preferences: true });
661
+ const updateCalls = window.dataLayer.filter(entry => {
662
+ const args = Array.from(entry);
663
+ return args[0] === 'consent' && args[1] === 'update';
664
+ });
665
+ const lastUpdate = Array.from(updateCalls[updateCalls.length - 1]);
666
+ const payload = lastUpdate[2];
667
+ expect(payload.analytics_storage).toBe('granted');
668
+ expect(payload.ad_storage).toBe('denied');
669
+ expect(payload.functionality_storage).toBe('granted');
670
+ expect(payload.personalization_storage).toBe('granted');
671
+ });
672
+ it('maps necessary to granted always', () => {
673
+ enableConsentMode(consent, {
674
+ mapping: {
675
+ necessary: ['security_storage'],
676
+ analytics: ['analytics_storage'],
677
+ },
678
+ });
679
+ const defaultCall = findGtagCall('consent', 'default');
680
+ expect(defaultCall.security_storage).toBe('granted');
681
+ expect(defaultCall.analytics_storage).toBe('denied');
682
+ });
683
+ it('dispose stops future updates', () => {
684
+ const dispose = enableConsentMode(consent, {
685
+ mapping: { analytics: ['analytics_storage'] },
686
+ });
687
+ dispose();
688
+ const countBefore = countGtagCalls('consent', 'update');
689
+ consent.set({ analytics: true });
690
+ const countAfter = countGtagCalls('consent', 'update');
691
+ expect(countAfter).toBe(countBefore);
692
+ });
693
+ it('handles clear() (consent revoked)', () => {
694
+ enableConsentMode(consent, {
695
+ mapping: { analytics: ['analytics_storage'] },
696
+ });
697
+ consent.set({ analytics: true });
698
+ const updatesBefore = countGtagCalls('consent', 'update');
699
+ consent.clear();
700
+ const updatesAfter = countGtagCalls('consent', 'update');
701
+ expect(updatesAfter).toBe(updatesBefore);
702
+ });
703
+ it('survives a throwing gtag and still subscribes', () => {
704
+ window.dataLayer = [];
705
+ window.gtag = vi.fn(() => { throw new Error('gtag broke'); });
706
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
707
+ const dispose = enableConsentMode(consent, {
708
+ mapping: { analytics: ['analytics_storage'] },
709
+ });
710
+ expect(spy).toHaveBeenCalledWith('[consentify] gtag call failed:', expect.any(Error));
711
+ window.gtag = function gtag() { window.dataLayer.push(arguments); };
712
+ consent.set({ analytics: true });
713
+ expect(countGtagCalls('consent', 'update')).toBeGreaterThanOrEqual(1);
714
+ dispose();
715
+ spy.mockRestore();
716
+ });
717
+ it('works with a minimal ConsentifySubscribable (not a full instance)', () => {
718
+ let state = { decision: 'unset' };
719
+ const listeners = new Set();
720
+ const subscribable = {
721
+ subscribe: (cb) => { listeners.add(cb); return () => listeners.delete(cb); },
722
+ get: () => state,
723
+ getServerSnapshot: () => ({ decision: 'unset' }),
724
+ };
725
+ const dispose = enableConsentMode(subscribable, {
726
+ mapping: { analytics: ['analytics_storage'] },
727
+ });
728
+ const defaultCall = findGtagCall('consent', 'default');
729
+ expect(defaultCall).toBeDefined();
730
+ expect(defaultCall.analytics_storage).toBe('denied');
731
+ state = {
732
+ decision: 'decided',
733
+ snapshot: {
734
+ policy: 'x',
735
+ givenAt: new Date().toISOString(),
736
+ choices: { necessary: true, analytics: true },
737
+ },
738
+ };
739
+ listeners.forEach(cb => cb());
740
+ const updateCall = findGtagCall('consent', 'update');
741
+ expect(updateCall).toBeDefined();
742
+ expect(updateCall.analytics_storage).toBe('granted');
743
+ dispose();
744
+ });
745
+ });
746
+ describe('server API — merge & cookie config', () => {
747
+ it('server.set() merges with existing consent from currentCookieHeader', () => {
748
+ const c = createConsentify({ policy: { categories: ['analytics', 'marketing'] } });
749
+ const header1 = c.server.set({ analytics: true });
750
+ const cookieVal = header1.split(';')[0];
751
+ const header2 = c.server.set({ marketing: true }, cookieVal);
752
+ const val = header2.split(';')[0].split('=').slice(1).join('=');
753
+ const snapshot = JSON.parse(decodeURIComponent(val));
754
+ expect(snapshot.choices.analytics).toBe(true);
755
+ expect(snapshot.choices.marketing).toBe(true);
756
+ });
757
+ it('SameSite=None forces Secure flag in server headers', () => {
758
+ const c = createConsentify({
759
+ policy: { categories: ['analytics'] },
760
+ cookie: { sameSite: 'None', secure: false },
761
+ });
762
+ const header = c.server.set({ analytics: true });
763
+ expect(header).toContain('SameSite=None');
764
+ expect(header).toContain('Secure');
765
+ });
766
+ it('domain option appears in Set-Cookie header', () => {
767
+ const c = createConsentify({
768
+ policy: { categories: ['analytics'] },
769
+ cookie: { domain: '.example.com' },
770
+ });
771
+ const header = c.server.set({ analytics: true });
772
+ expect(header).toContain('Domain=.example.com');
773
+ });
774
+ it('domain option appears in clear header', () => {
775
+ const c = createConsentify({
776
+ policy: { categories: ['analytics'] },
777
+ cookie: { domain: '.example.com' },
778
+ });
779
+ const header = c.server.clear();
780
+ expect(header).toContain('Domain=.example.com');
781
+ });
782
+ it('clear() returns the same header regardless of input', () => {
783
+ const c = createConsentify({ policy: { categories: ['analytics'] } });
784
+ const result1 = c.clear('foo=bar');
785
+ const result2 = c.clear('baz=qux');
786
+ expect(result1).toBe(result2);
787
+ });
788
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consentify/core",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Minimal headless cookie consent SDK (TypeScript, SSR-ready, lazy-init).",
5
5
  "author": {
6
6
  "name": "Roman Denysov",