@owlmeans/consent 0.1.18-rc.8 → 0.1.18

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.
Files changed (120) hide show
  1. package/README.md +229 -0
  2. package/build/consts.d.ts +84 -23
  3. package/build/consts.d.ts.map +1 -1
  4. package/build/consts.js +110 -29
  5. package/build/consts.js.map +1 -1
  6. package/build/consts.local.d.ts +12 -0
  7. package/build/consts.local.d.ts.map +1 -0
  8. package/build/consts.local.js +12 -0
  9. package/build/consts.local.js.map +1 -0
  10. package/build/geo/types.d.ts +47 -0
  11. package/build/geo/types.d.ts.map +1 -0
  12. package/build/geo/types.js +2 -0
  13. package/build/geo/types.js.map +1 -0
  14. package/build/geo.d.ts +4 -0
  15. package/build/geo.d.ts.map +1 -0
  16. package/build/geo.js +96 -0
  17. package/build/geo.js.map +1 -0
  18. package/build/gtobserver/types.d.ts +75 -0
  19. package/build/gtobserver/types.d.ts.map +1 -0
  20. package/build/gtobserver/types.js +2 -0
  21. package/build/gtobserver/types.js.map +1 -0
  22. package/build/gtobserver.d.ts +7 -0
  23. package/build/gtobserver.d.ts.map +1 -0
  24. package/build/gtobserver.js +142 -0
  25. package/build/gtobserver.js.map +1 -0
  26. package/build/i18n/be.json +9 -4
  27. package/build/i18n/de.json +9 -4
  28. package/build/i18n/en.json +9 -4
  29. package/build/i18n/es.json +9 -4
  30. package/build/i18n/fr.json +9 -4
  31. package/build/i18n/pl.json +9 -4
  32. package/build/i18n/ru.json +9 -4
  33. package/build/i18n/types.d.ts +11 -0
  34. package/build/i18n/types.d.ts.map +1 -0
  35. package/build/i18n/types.js +2 -0
  36. package/build/i18n/types.js.map +1 -0
  37. package/build/i18n/uk.json +9 -4
  38. package/build/i18n.d.ts +4 -14
  39. package/build/i18n.d.ts.map +1 -1
  40. package/build/i18n.js +12 -27
  41. package/build/i18n.js.map +1 -1
  42. package/build/index.d.ts +13 -6
  43. package/build/index.d.ts.map +1 -1
  44. package/build/index.js +5 -4
  45. package/build/index.js.map +1 -1
  46. package/build/linker/types.d.ts +59 -0
  47. package/build/linker/types.d.ts.map +1 -0
  48. package/build/linker/types.js +2 -0
  49. package/build/linker/types.js.map +1 -0
  50. package/build/linker.d.ts +5 -76
  51. package/build/linker.d.ts.map +1 -1
  52. package/build/linker.js +290 -339
  53. package/build/linker.js.map +1 -1
  54. package/build/plugins/types.d.ts +42 -0
  55. package/build/plugins/types.d.ts.map +1 -0
  56. package/build/plugins/types.js +2 -0
  57. package/build/plugins/types.js.map +1 -0
  58. package/build/plugins.d.ts +4 -69
  59. package/build/plugins.d.ts.map +1 -1
  60. package/build/plugins.js +73 -62
  61. package/build/plugins.js.map +1 -1
  62. package/build/storage/types.d.ts +31 -0
  63. package/build/storage/types.d.ts.map +1 -0
  64. package/build/storage/types.js +2 -0
  65. package/build/storage/types.js.map +1 -0
  66. package/build/storage.d.ts +6 -22
  67. package/build/storage.d.ts.map +1 -1
  68. package/build/storage.js +72 -81
  69. package/build/storage.js.map +1 -1
  70. package/build/store.d.ts +2 -2
  71. package/build/store.d.ts.map +1 -1
  72. package/build/store.js +116 -67
  73. package/build/store.js.map +1 -1
  74. package/build/types.d.ts +147 -26
  75. package/build/types.d.ts.map +1 -1
  76. package/build/types.local.d.ts +9 -0
  77. package/build/types.local.d.ts.map +1 -0
  78. package/build/types.local.js +2 -0
  79. package/build/types.local.js.map +1 -0
  80. package/package.json +3 -3
  81. package/src/consts.local.ts +16 -0
  82. package/src/consts.ts +126 -34
  83. package/src/geo/types.ts +49 -0
  84. package/src/geo.ts +122 -0
  85. package/src/gtobserver/types.ts +73 -0
  86. package/src/gtobserver.ts +182 -0
  87. package/src/i18n/be.json +9 -4
  88. package/src/i18n/de.json +9 -4
  89. package/src/i18n/en.json +9 -4
  90. package/src/i18n/es.json +9 -4
  91. package/src/i18n/fr.json +9 -4
  92. package/src/i18n/pl.json +9 -4
  93. package/src/i18n/ru.json +9 -4
  94. package/src/i18n/types.ts +11 -0
  95. package/src/i18n/uk.json +9 -4
  96. package/src/i18n.ts +20 -32
  97. package/src/index.ts +13 -13
  98. package/src/linker/types.ts +59 -0
  99. package/src/linker.ts +301 -364
  100. package/src/plugins/types.ts +42 -0
  101. package/src/plugins.ts +82 -106
  102. package/src/storage/types.ts +31 -0
  103. package/src/storage.ts +75 -77
  104. package/src/store.ts +122 -69
  105. package/src/types.local.ts +9 -0
  106. package/src/types.ts +153 -25
  107. package/tests/consent.spec.ts +26 -28
  108. package/tests/geo.spec.ts +474 -0
  109. package/tests/linker.spec.ts +144 -309
  110. package/tests/tracking.spec.ts +14 -14
  111. package/build/functional.d.ts +0 -35
  112. package/build/functional.d.ts.map +0 -1
  113. package/build/functional.js +0 -67
  114. package/build/functional.js.map +0 -1
  115. package/build/gtm.d.ts +0 -72
  116. package/build/gtm.d.ts.map +0 -1
  117. package/build/gtm.js +0 -182
  118. package/build/gtm.js.map +0 -1
  119. package/src/functional.ts +0 -73
  120. package/src/gtm.ts +0 -220
@@ -1,14 +1,12 @@
1
1
  import { describe, test, expect, beforeEach } from 'bun:test'
2
2
  import {
3
3
  CONSENT_ANALYTICS, CONSENT_ESSENTIAL, CONSENT_EVENT, CONSENT_KEY, CONSENT_LOCALES,
4
- CONSENT_MARKETING, CONSENT_SCHEMA_VERSION, DEFAULT_CONSENT_CATEGORIES,
4
+ CONSENT_MARKETING, CONSENT_SCHEMA_VERSION, DEFAULT_CONSENT_CATEGORIES, DEFAULT_CONSENT_MESSAGES,
5
5
  } from '../src/consts.js'
6
- import { migrateConsent, readConsent, writeConsent, clearConsent } from '../src/storage.js'
7
- import { applyConsent, consentDefaults, consentUpdate, consentBootstrapScript } from '../src/gtm.js'
6
+ import { consentStorageHelper } from '../src/storage.js'
7
+ import { consentModeHelper } from '../src/gtobserver.js'
8
8
  import { makeConsentStore } from '../src/store.js'
9
- import {
10
- DEFAULT_CONSENT_MESSAGES, defaultConsentTranslate, interpolate, normalizeLocale,
11
- } from '../src/i18n.js'
9
+ import { consentI18nHelper } from '../src/i18n.js'
12
10
  import type { ConsentCategory } from '../src/types.js'
13
11
 
14
12
  /** A minimal browser: just the two stores this package writes to. */
@@ -41,33 +39,33 @@ beforeEach(() => {
41
39
 
42
40
  describe('storage', () => {
43
41
  test('a record round-trips through localStorage', () => {
44
- writeConsent({ [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false })
42
+ consentStorageHelper.writeConsent({ [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false })
45
43
 
46
- expect(readConsent()?.[CONSENT_ANALYTICS]).toBe(true)
47
- expect(readConsent()?.[CONSENT_MARKETING]).toBe(false)
44
+ expect(consentStorageHelper.readConsent()?.[CONSENT_ANALYTICS]).toBe(true)
45
+ expect(consentStorageHelper.readConsent()?.[CONSENT_MARKETING]).toBe(false)
48
46
  })
49
47
 
50
48
  test('the cookie answers when localStorage does not', () => {
51
- writeConsent({ [CONSENT_ANALYTICS]: true })
49
+ consentStorageHelper.writeConsent({ [CONSENT_ANALYTICS]: true })
52
50
  env.store.clear()
53
51
 
54
- expect(readConsent()?.[CONSENT_ANALYTICS]).toBe(true)
52
+ expect(consentStorageHelper.readConsent()?.[CONSENT_ANALYTICS]).toBe(true)
55
53
  })
56
54
 
57
55
  test('the cookie is path-wide, SameSite=Lax and long-lived', () => {
58
- writeConsent({ [CONSENT_ANALYTICS]: true })
56
+ consentStorageHelper.writeConsent({ [CONSENT_ANALYTICS]: true })
59
57
 
60
58
  expect(env.raw()).toContain(`${CONSENT_KEY}=`)
61
59
  // The cookie fragment the browser stub keeps is just the pair, so the attributes are asserted
62
60
  // on what `writeConsent` produced rather than on what a real browser would retain.
63
- expect(readConsent()).not.toBeNull()
61
+ expect(consentStorageHelper.readConsent()).not.toBeNull()
64
62
  })
65
63
 
66
64
  test('a legacy record — the owlmeans.com visitor — migrates instead of being discarded', () => {
67
65
  // Exactly what the previous widget wrote: two categories, no version, essential implicit.
68
66
  const legacy = { analytics: true, marketing: false }
69
67
 
70
- const migrated = migrateConsent(legacy)
68
+ const migrated = consentStorageHelper.migrateConsent(legacy)
71
69
 
72
70
  expect(migrated?.[CONSENT_ESSENTIAL]).toBe(true)
73
71
  expect(migrated?.[CONSENT_ANALYTICS]).toBe(true)
@@ -77,19 +75,19 @@ describe('storage', () => {
77
75
  test('a current record is returned unchanged', () => {
78
76
  const current = { essential: true, analytics: false, v: CONSENT_SCHEMA_VERSION }
79
77
 
80
- expect(migrateConsent(current)).toBe(current)
78
+ expect(consentStorageHelper.migrateConsent(current)).toBe(current)
81
79
  })
82
80
 
83
81
  test('nothing stored reads as null', () => {
84
- clearConsent()
82
+ consentStorageHelper.clearConsent()
85
83
 
86
- expect(readConsent()).toBeNull()
84
+ expect(consentStorageHelper.readConsent()).toBeNull()
87
85
  })
88
86
  })
89
87
 
90
88
  describe('consent mode signals', () => {
91
89
  test('defaults deny everything except security storage', () => {
92
- const defaults = consentDefaults()
90
+ const defaults = consentModeHelper.consentDefaults()
93
91
 
94
92
  expect(defaults.analytics_storage).toBe('denied')
95
93
  expect(defaults.ad_storage).toBe('denied')
@@ -99,7 +97,7 @@ describe('consent mode signals', () => {
99
97
  })
100
98
 
101
99
  test('an update maps each category onto the signals it drives', () => {
102
- const update = consentUpdate({ [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false })
100
+ const update = consentModeHelper.consentUpdate({ [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false })
103
101
 
104
102
  expect(update.analytics_storage).toBe('granted')
105
103
  expect(update.ad_storage).toBe('denied')
@@ -112,8 +110,8 @@ describe('consent mode signals', () => {
112
110
  { key: 'ads', labelKey: 'a', descriptionKey: 'b', signals: ['ad_storage'] },
113
111
  ]
114
112
 
115
- expect(Object.keys(consentDefaults(categories))).toEqual(['ad_storage'])
116
- expect(consentUpdate({ ads: true }, categories)).toEqual({ ad_storage: 'granted' })
113
+ expect(Object.keys(consentModeHelper.consentDefaults(categories))).toEqual(['ad_storage'])
114
+ expect(consentModeHelper.consentUpdate({ ads: true }, categories)).toEqual({ ad_storage: 'granted' })
117
115
  })
118
116
  })
119
117
 
@@ -124,7 +122,7 @@ describe('applyConsent dispatches a DOM event', () => {
124
122
  ;(globalThis as any).addEventListener(CONSENT_EVENT, listener)
125
123
 
126
124
  try {
127
- applyConsent({ [CONSENT_ANALYTICS]: true })
125
+ consentModeHelper.applyConsent({ [CONSENT_ANALYTICS]: true })
128
126
 
129
127
  expect(seen).toEqual([{ record: { [CONSENT_ANALYTICS]: true } }])
130
128
  } finally {
@@ -138,7 +136,7 @@ describe('applyConsent dispatches a DOM event', () => {
138
136
  ;(globalThis as any).addEventListener(CONSENT_EVENT, listener)
139
137
 
140
138
  try {
141
- applyConsent({ [CONSENT_ANALYTICS]: true }, { silent: true })
139
+ consentModeHelper.applyConsent({ [CONSENT_ANALYTICS]: true }, { silent: true })
142
140
 
143
141
  expect(seen).toEqual([])
144
142
  } finally {
@@ -148,7 +146,7 @@ describe('applyConsent dispatches a DOM event', () => {
148
146
  })
149
147
 
150
148
  describe('the bootstrap script', () => {
151
- const script = consentBootstrapScript()
149
+ const script = consentModeHelper.consentBootstrapScript()
152
150
 
153
151
  test('declares the defaults before anything else can', () => {
154
152
  expect(script).toContain("'consent','default'")
@@ -242,15 +240,15 @@ describe('the built-in translations', () => {
242
240
  })
243
241
 
244
242
  test('an unknown locale falls back to English rather than to a raw key', () => {
245
- expect(normalizeLocale('kl')).toBe('en')
246
- expect(defaultConsentTranslate('kl')('title', 'fallback')).toBe('Cookie Preferences')
243
+ expect(consentI18nHelper.normalizeLocale('kl')).toBe('en')
244
+ expect(consentI18nHelper.defaultConsentTranslate('kl')('title', 'fallback')).toBe('Cookie Preferences')
247
245
  })
248
246
 
249
247
  test('a key nobody translated returns the supplied default', () => {
250
- expect(defaultConsentTranslate('en')('nope', 'the default')).toBe('the default')
248
+ expect(consentI18nHelper.defaultConsentTranslate('en')('nope', 'the default')).toBe('the default')
251
249
  })
252
250
 
253
251
  test('placeholders interpolate', () => {
254
- expect(interpolate('kept for {{days}} days', { days: 365 })).toBe('kept for 365 days')
252
+ expect(consentI18nHelper.interpolate('kept for {{days}} days', { days: 365 })).toBe('kept for 365 days')
255
253
  })
256
254
  })
@@ -0,0 +1,474 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
2
+ import {
3
+ CONSENT_ANALYTICS, CONSENT_AUTO_MAX_AGE, CONSENT_ESSENTIAL, CONSENT_KEY, CONSENT_MARKETING,
4
+ CONSENT_REQUIRED_COUNTRIES, CONSENT_TRACE_PATH,
5
+ } from '../src/consts.js'
6
+ import { consentGeoHelper } from '../src/geo.js'
7
+ import { consentModeHelper } from '../src/gtobserver.js'
8
+ import { consentLinkHelper } from '../src/linker.js'
9
+ import { consentPluginHelper } from '../src/plugins.js'
10
+ import { makeConsentStore } from '../src/store.js'
11
+ import { consentStorageHelper } from '../src/storage.js'
12
+ import type { ConsentGeoPlugin, ConsentOptions, ConsentRecord } from '../src/types.js'
13
+
14
+ /** Storage, a cookie jar, `<html>` attributes, a `location`/`history` pair and a fake trace endpoint. */
15
+ const browser = () => {
16
+ const store = new Map<string, string>()
17
+ const attributes = new Map<string, string>()
18
+ let cookie = ''
19
+ let current = new URL('https://site.test/')
20
+ let trace: { status: number, body: string } | 'throw' | 'hang' = { status: 200, body: '' }
21
+ const calls: { path: string, init?: RequestInit }[] = []
22
+ ;(globalThis as any).localStorage = {
23
+ getItem: (k: string) => store.get(k) ?? null,
24
+ setItem: (k: string, v: string) => { store.set(k, v) },
25
+ removeItem: (k: string) => { store.delete(k) },
26
+ }
27
+ ;(globalThis as any).document = {
28
+ referrer: '',
29
+ documentElement: {
30
+ lang: '',
31
+ setAttribute: (name: string, value: string) => { attributes.set(name, value) },
32
+ },
33
+ get cookie() { return cookie },
34
+ set cookie(value: string) {
35
+ const [pair] = value.split(';')
36
+ const [name] = pair.split('=')
37
+ const rest = cookie === '' ? [] : cookie.split('; ').filter(entry => !entry.startsWith(`${name}=`))
38
+ // An expired write is a removal, the way a browser treats it.
39
+ cookie = [...rest, ...(value.includes('1970') ? [] : [pair])].join('; ')
40
+ },
41
+ addEventListener: () => {},
42
+ removeEventListener: () => {},
43
+ }
44
+ ;(globalThis as any).location = new Proxy({}, {
45
+ get: (_t, prop) => (current as unknown as Record<string, unknown>)[prop as string],
46
+ })
47
+ ;(globalThis as any).history = {
48
+ state: null,
49
+ replaceState: (_state: unknown, _title: string, next: string) => { current = new URL(next, current.origin) },
50
+ }
51
+ ;(globalThis as any).window = globalThis
52
+ ;(globalThis as any).fetch = async (path: string, init?: RequestInit): Promise<Response> => {
53
+ calls.push({ path, init })
54
+ if (trace === 'throw') {
55
+ throw new TypeError('network down')
56
+ }
57
+ if (trace === 'hang') {
58
+ return await new Promise<Response>(() => {})
59
+ }
60
+
61
+ return new Response(trace.body, { status: trace.status })
62
+ }
63
+
64
+ return {
65
+ store, attributes, calls,
66
+ trace: (answer: typeof trace) => { trace = answer },
67
+ located: (country: string) => {
68
+ trace = { status: 200, body: `fl=1f1\nh=site.test\nip=203.0.113.1\nts=1\nvisit_scheme=https\ncolo=WAW\nloc=${country}\ntls=TLSv1.3\n` }
69
+ },
70
+ goto: (href: string, referrer = '') => {
71
+ current = new URL(href)
72
+ ;(globalThis as any).document.referrer = referrer
73
+ },
74
+ reset: () => {
75
+ store.clear(); attributes.clear(); cookie = ''; calls.length = 0
76
+ current = new URL('https://site.test/'); (globalThis as any).document.referrer = ''
77
+ },
78
+ }
79
+ }
80
+
81
+ const env = browser()
82
+ const cloudflare: ConsentOptions = { silent: true, geo: { cloudflare: true } }
83
+ const nowSeconds = (): number => Math.floor(Date.now() / 1000)
84
+ const gpc = (value: boolean | undefined): void => {
85
+ Object.defineProperty(globalThis.navigator, 'globalPrivacyControl', { value, configurable: true })
86
+ }
87
+
88
+ beforeEach(() => {
89
+ env.reset()
90
+ env.located('US')
91
+ delete (globalThis as any).dataLayer
92
+ delete (globalThis as any).cookieConsentSetup
93
+ })
94
+
95
+ afterEach(() => {
96
+ for (const alias of ['cloudflare', 'app-geo', 'second-geo', 'linker']) {
97
+ consentPluginHelper.unregisterConsentPlugin(alias)
98
+ }
99
+ gpc(undefined)
100
+ })
101
+
102
+ describe('the country lists', () => {
103
+ test('the EU, the UK and Switzerland ask; the United States does not', () => {
104
+ for (const country of ['PL', 'DE', 'FR', 'IE', 'GR', 'RE', 'NO', 'GB', 'CH', 'BR']) {
105
+ expect({ country, ask: CONSENT_REQUIRED_COUNTRIES.includes(country) }).toEqual({ country, ask: true })
106
+ }
107
+ for (const country of ['US', 'JP', 'AU', 'SG', 'IN', 'MX']) {
108
+ expect({ country, ask: CONSENT_REQUIRED_COUNTRIES.includes(country) }).toEqual({ country, ask: false })
109
+ }
110
+ })
111
+
112
+ test('every entry is a distinct upper-case alpha-2 code', () => {
113
+ expect(CONSENT_REQUIRED_COUNTRIES.every(code => /^[A-Z]{2}$/.test(code))).toBe(true)
114
+ expect(new Set(CONSENT_REQUIRED_COUNTRIES).size).toBe(CONSENT_REQUIRED_COUNTRIES.length)
115
+ })
116
+ })
117
+
118
+ describe('consentGeoHelper', () => {
119
+ test('parseTrace reads key=value lines, CRLF included, and nothing out of HTML', () => {
120
+ expect(consentGeoHelper.parseTrace('fl=1\r\ncolo=WAW\r\nloc=PL\r\n')).toEqual({ fl: '1', colo: 'WAW', loc: 'PL' })
121
+ expect(consentGeoHelper.parseTrace('<!doctype html><html lang="en"><body>app</body></html>')).toEqual({})
122
+ })
123
+
124
+ test('requiresConsent asks inside the list and for anything that names no country', () => {
125
+ expect(consentGeoHelper.requiresConsent('PL')).toBe(true)
126
+ expect(consentGeoHelper.requiresConsent('de')).toBe(true)
127
+ expect(consentGeoHelper.requiresConsent('US')).toBe(false)
128
+ for (const unknown of ['XX', 'T1', 'EU', '', 'USA', null]) {
129
+ expect({ unknown, ask: consentGeoHelper.requiresConsent(unknown) }).toEqual({ unknown, ask: true })
130
+ }
131
+ expect(consentGeoHelper.requiresConsent('US', { geo: { countries: ['US'] } })).toBe(true)
132
+ expect(consentGeoHelper.requiresConsent('PL', { geo: { countries: ['US'] } })).toBe(false)
133
+ })
134
+
135
+ test('the Cloudflare locator reads the same-origin trace, without cookies and uncached', async () => {
136
+ env.located('PL')
137
+
138
+ expect(await consentGeoHelper.cloudflareLocator().locate(cloudflare)).toEqual({ country: 'PL' })
139
+ expect(env.calls[0].path).toBe(CONSENT_TRACE_PATH)
140
+ expect(env.calls[0].init).toMatchObject({ credentials: 'omit', cache: 'no-store' })
141
+ })
142
+
143
+ test('the Cloudflare locator honours a path override', async () => {
144
+ await consentGeoHelper.cloudflareLocator().locate({ geo: { cloudflare: { path: '/edge/trace' } } })
145
+
146
+ expect(env.calls[0].path).toBe('/edge/trace')
147
+ })
148
+
149
+ test('the Cloudflare locator refuses a non-2xx answer, an HTML fallback page and being switched off', async () => {
150
+ env.trace({ status: 404, body: 'not found' })
151
+ await expect(consentGeoHelper.cloudflareLocator().locate(cloudflare)).rejects.toThrow()
152
+
153
+ env.trace({ status: 200, body: '<!doctype html><html><body>index</body></html>' })
154
+ await expect(consentGeoHelper.cloudflareLocator().locate(cloudflare)).rejects.toThrow()
155
+
156
+ await expect(consentGeoHelper.cloudflareLocator().locate({ geo: {} })).rejects.toThrow()
157
+ })
158
+
159
+ test('locators run highest priority first, and a throw or an unusable code hands over to the next', async () => {
160
+ const order: string[] = []
161
+ consentPluginHelper.registerConsentPlugin({
162
+ alias: 'app-geo', priority: 10,
163
+ locate: async () => { order.push('app'); throw new Error('no idea') },
164
+ } satisfies ConsentGeoPlugin)
165
+ consentPluginHelper.registerConsentPlugin({
166
+ alias: 'second-geo', priority: 5,
167
+ locate: async () => { order.push('second'); return { country: 'XX' } },
168
+ } satisfies ConsentGeoPlugin)
169
+ consentPluginHelper.registerConsentPlugin(consentGeoHelper.cloudflareLocator())
170
+ env.located('fr')
171
+
172
+ expect(await consentPluginHelper.locateConsent(cloudflare)).toEqual({ country: 'FR' })
173
+ expect(order).toEqual(['app', 'second'])
174
+ })
175
+
176
+ test('with no locator able to answer, locating rejects and deciding asks', async () => {
177
+ await expect(consentPluginHelper.locateConsent(cloudflare)).rejects.toThrow()
178
+ expect(await consentGeoHelper.decide(cloudflare)).toBe('ask')
179
+ })
180
+
181
+ test('decide: outside the list is automatic, inside asks, a lookup past the timeout asks', async () => {
182
+ consentPluginHelper.registerConsentPlugin(consentGeoHelper.cloudflareLocator())
183
+ expect(await consentGeoHelper.decide(cloudflare)).toBe('auto')
184
+
185
+ env.located('PL')
186
+ expect(await consentGeoHelper.decide(cloudflare)).toBe('ask')
187
+
188
+ env.trace('hang')
189
+ expect(await consentGeoHelper.decide({ geo: { cloudflare: true, timeout: 20 } })).toBe('ask')
190
+ })
191
+
192
+ test('the automatic decision grants everything, or only the required under Global Privacy Control', () => {
193
+ const all = consentGeoHelper.automaticRecord()
194
+ expect(all).toMatchObject({ [CONSENT_ESSENTIAL]: true, [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: true })
195
+ expect(Math.abs((all.auto ?? 0) - nowSeconds())).toBeLessThanOrEqual(1)
196
+
197
+ gpc(true)
198
+ expect(consentGeoHelper.automaticRecord())
199
+ .toMatchObject({ [CONSENT_ESSENTIAL]: true, [CONSENT_ANALYTICS]: false, [CONSENT_MARKETING]: false })
200
+ })
201
+
202
+ test('autoState: fresh, stale, from the future, explicit', () => {
203
+ expect(consentGeoHelper.autoState({ auto: nowSeconds() - 10 })).toBe('fresh')
204
+ expect(consentGeoHelper.autoState({ auto: nowSeconds() - CONSENT_AUTO_MAX_AGE - 5 })).toBe('stale')
205
+ expect(consentGeoHelper.autoState({ auto: nowSeconds() + 3600 })).toBe('stale')
206
+ expect(consentGeoHelper.autoState({ analytics: true })).toBeNull()
207
+ expect(consentGeoHelper.autoState(null)).toBeNull()
208
+ })
209
+
210
+ test('the gate is on only with geo set and something able to answer', () => {
211
+ expect(consentGeoHelper.enabled({})).toBe(false)
212
+ expect(consentGeoHelper.enabled({ geo: {} })).toBe(false)
213
+ expect(consentGeoHelper.enabled(cloudflare)).toBe(true)
214
+ consentPluginHelper.registerConsentPlugin({ alias: 'app-geo', locate: async () => ({ country: 'US' }) })
215
+ expect(consentGeoHelper.enabled({ geo: {} })).toBe(true)
216
+ })
217
+ })
218
+
219
+ describe('the store, with the geo gate', () => {
220
+ test('without geo it asks at once and never fetches — unchanged', () => {
221
+ const store = makeConsentStore()
222
+ store.init({ silent: true })
223
+
224
+ expect(store.get()).toMatchObject({ open: true, reason: 'initial', locating: null })
225
+ expect(env.calls.length).toBe(0)
226
+ })
227
+
228
+ test('outside the list: locating first, then an automatic decision, stored and applied', async () => {
229
+ const store = makeConsentStore()
230
+ const seen: (string | null)[] = []
231
+ store.subscribe(state => seen.push(state.locating))
232
+ store.init(cloudflare)
233
+
234
+ expect(store.get()).toMatchObject({ open: false, locating: 'first', record: null })
235
+ expect(env.attributes.get('data-consent')).toBe('locating')
236
+
237
+ const settled = await store.settled()
238
+
239
+ expect(settled).toMatchObject({ open: false, locating: null })
240
+ expect(settled.record).toMatchObject({ [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: true })
241
+ expect(typeof consentStorageHelper.readConsent()?.auto).toBe('number')
242
+ expect(store.granted(CONSENT_ANALYTICS)).toBe(true)
243
+ expect(env.attributes.get('data-consent')).toBe('decided')
244
+ expect(seen).toEqual(['first', null])
245
+ })
246
+
247
+ test('inside the list it asks, and nothing is stored', async () => {
248
+ env.located('PL')
249
+ const store = makeConsentStore()
250
+ store.init(cloudflare)
251
+ await store.settled()
252
+
253
+ expect(store.get()).toMatchObject({ open: true, reason: 'initial', record: null })
254
+ expect(consentStorageHelper.readConsent()).toBeNull()
255
+ expect(env.attributes.get('data-consent')).toBe('open')
256
+ })
257
+
258
+ test('a failed lookup asks', async () => {
259
+ env.trace('throw')
260
+ const store = makeConsentStore()
261
+ store.init(cloudflare)
262
+ await store.settled()
263
+
264
+ expect(store.get()).toMatchObject({ open: true, reason: 'initial' })
265
+ })
266
+
267
+ test('Global Privacy Control: the automatic decision is mandatory-only', async () => {
268
+ gpc(true)
269
+ const store = makeConsentStore()
270
+ store.init(cloudflare)
271
+ await store.settled()
272
+
273
+ expect(store.get().record).toMatchObject({ [CONSENT_ESSENTIAL]: true, [CONSENT_ANALYTICS]: false })
274
+ expect(store.granted(CONSENT_ANALYTICS)).toBe(false)
275
+ })
276
+
277
+ test('a decision saved while locating wins over the location', async () => {
278
+ const store = makeConsentStore()
279
+ store.init(cloudflare)
280
+ store.save({ [CONSENT_ESSENTIAL]: true, [CONSENT_ANALYTICS]: false, [CONSENT_MARKETING]: false })
281
+ await store.settled()
282
+
283
+ expect(store.get().record?.[CONSENT_ANALYTICS]).toBe(false)
284
+ expect(consentStorageHelper.readConsent()?.auto).toBeUndefined()
285
+ })
286
+
287
+ test('the window opened for signing in while locating is never decided over', async () => {
288
+ const store = makeConsentStore()
289
+ store.init(cloudflare)
290
+ store.open('login')
291
+ await store.settled()
292
+
293
+ expect(store.get()).toMatchObject({ open: true, reason: 'login', record: null, locating: null })
294
+ })
295
+
296
+ test('a second init while locating starts no second lookup', async () => {
297
+ const store = makeConsentStore()
298
+ store.init(cloudflare)
299
+ store.init(cloudflare)
300
+ store.init({ silent: true })
301
+ await store.settled()
302
+
303
+ expect(env.calls.length).toBe(1)
304
+ })
305
+
306
+ test('an init that brings geo late takes back the ask an earlier init raised', async () => {
307
+ const store = makeConsentStore()
308
+ store.init({ silent: true })
309
+ expect(store.get().open).toBe(true)
310
+
311
+ store.init(cloudflare)
312
+ expect(store.get()).toMatchObject({ open: false, locating: 'first' })
313
+ await store.settled()
314
+
315
+ expect(store.get().record?.auto).toBeNumber()
316
+ })
317
+
318
+ test('a fresh automatic decision is applied as it is, with no lookup', () => {
319
+ consentStorageHelper.writeConsent({ ...consentGeoHelper.automaticRecord(), auto: nowSeconds() - 60 })
320
+ const store = makeConsentStore()
321
+ store.init(cloudflare)
322
+
323
+ expect(store.get()).toMatchObject({ open: false, locating: null })
324
+ expect(store.granted(CONSENT_ANALYTICS)).toBe(true)
325
+ expect(env.calls.length).toBe(0)
326
+ })
327
+
328
+ test('an explicit decision is never located', () => {
329
+ consentStorageHelper.writeConsent({ [CONSENT_ESSENTIAL]: true, [CONSENT_ANALYTICS]: false })
330
+ const store = makeConsentStore()
331
+ store.init(cloudflare)
332
+
333
+ expect(store.get()).toMatchObject({ open: false, locating: null })
334
+ expect(env.calls.length).toBe(0)
335
+ })
336
+
337
+ test('a stale automatic decision is held back and re-checked: still outside, it is renewed', async () => {
338
+ const stale = nowSeconds() - CONSENT_AUTO_MAX_AGE - 60
339
+ consentStorageHelper.writeConsent({ ...consentGeoHelper.automaticRecord(), auto: stale })
340
+ const store = makeConsentStore()
341
+ store.init(cloudflare)
342
+
343
+ expect(store.get()).toMatchObject({ record: null, open: false, locating: 'recheck' })
344
+ expect(store.granted(CONSENT_ANALYTICS)).toBe(false)
345
+ await store.settled()
346
+
347
+ expect(store.get().record?.auto).toBeGreaterThan(stale)
348
+ expect(store.granted(CONSENT_ANALYTICS)).toBe(true)
349
+ })
350
+
351
+ test('a stale automatic decision met inside the list is dropped, and the visitor is asked', async () => {
352
+ env.located('DE')
353
+ consentStorageHelper.writeConsent({ ...consentGeoHelper.automaticRecord(), auto: nowSeconds() - CONSENT_AUTO_MAX_AGE - 60 })
354
+ const store = makeConsentStore()
355
+ store.init(cloudflare)
356
+ await store.settled()
357
+
358
+ expect(store.get()).toMatchObject({ open: true, reason: 'initial', record: null })
359
+ expect(consentStorageHelper.readConsent()).toBeNull()
360
+ })
361
+
362
+ test('an explicit save drops the automatic marker', () => {
363
+ const store = makeConsentStore()
364
+ store.init({ silent: true })
365
+ store.save({ ...consentGeoHelper.automaticRecord(), [CONSENT_MARKETING]: false })
366
+
367
+ expect(store.get().record?.auto).toBeUndefined()
368
+ expect(consentStorageHelper.readConsent()?.auto).toBeUndefined()
369
+ })
370
+
371
+ test('an application locator decides without Cloudflare', async () => {
372
+ consentPluginHelper.registerConsentPlugin({ alias: 'app-geo', locate: async () => ({ country: 'JP' }) })
373
+ const store = makeConsentStore()
374
+ store.init({ silent: true, geo: {} })
375
+ await store.settled()
376
+
377
+ expect(store.get().record?.auto).toBeNumber()
378
+ expect(env.calls.length).toBe(0)
379
+ })
380
+ })
381
+
382
+ describe('the head scripts and an automatic decision', () => {
383
+ const run = (script: string) => {
384
+ // eslint-disable-next-line no-new-func
385
+ new Function(script)()
386
+ }
387
+ const updates = (): Record<string, string>[] => ((globalThis as any).dataLayer ?? [])
388
+ .filter((entry: IArguments) => entry[0] === 'consent' && entry[1] === 'update')
389
+ .map((entry: IArguments) => entry[2])
390
+
391
+ test('the bootstrap applies a fresh automatic decision and skips a stale one', () => {
392
+ consentStorageHelper.writeConsent({ ...consentGeoHelper.automaticRecord(), auto: nowSeconds() })
393
+ run(consentModeHelper.consentBootstrapScript())
394
+ expect(updates()[0]?.analytics_storage).toBe('granted')
395
+
396
+ delete (globalThis as any).dataLayer
397
+ delete (globalThis as any).cookieConsentSetup
398
+ consentStorageHelper.writeConsent({ ...consentGeoHelper.automaticRecord(), auto: nowSeconds() - CONSENT_AUTO_MAX_AGE - 1 })
399
+ run(consentModeHelper.consentBootstrapScript())
400
+ expect(updates()).toEqual([])
401
+ })
402
+
403
+ test('the gate loads for a fresh automatic grant and waits on a stale one', () => {
404
+ ;(globalThis as any).__loaded = 0
405
+ const listeners: (() => void)[] = []
406
+ const add = (globalThis as any).addEventListener
407
+ ;(globalThis as any).addEventListener = (_type: string, cb: () => void) => { listeners.push(cb) }
408
+ try {
409
+ consentStorageHelper.writeConsent({ ...consentGeoHelper.automaticRecord(), auto: nowSeconds() })
410
+ run(consentModeHelper.consentGateScript('window.__loaded++'))
411
+ expect((globalThis as any).__loaded).toBe(1)
412
+
413
+ consentStorageHelper.writeConsent({ ...consentGeoHelper.automaticRecord(), auto: nowSeconds() - CONSENT_AUTO_MAX_AGE - 1 })
414
+ run(consentModeHelper.consentGateScript('window.__loaded++'))
415
+ expect((globalThis as any).__loaded).toBe(1)
416
+ expect(listeners.length).toBe(1)
417
+ } finally {
418
+ ;(globalThis as any).addEventListener = add
419
+ delete (globalThis as any).__loaded
420
+ }
421
+ })
422
+ })
423
+
424
+ describe('the linker and an automatic decision', () => {
425
+ const DOMAINS = ['site.test', 'platform.test']
426
+
427
+ test('an automatic decision never travels as the visitor\'s own', () => {
428
+ const record: ConsentRecord = { ...consentGeoHelper.automaticRecord() }
429
+ const decoded = consentLinkHelper.decodeConsentLink(consentLinkHelper.encodeConsentLink(record))
430
+
431
+ expect(decoded?.c).toEqual({})
432
+ expect(consentLinkHelper.consentLinker().decorate?.(new URL('https://platform.test/'), record, { linker: { domains: DOMAINS } })).toBeNull()
433
+ })
434
+
435
+ test('the store adopts an explicit decision carried in over an automatic one', () => {
436
+ consentStorageHelper.writeConsent({ ...consentGeoHelper.automaticRecord(), auto: nowSeconds() })
437
+ const incoming = { [CONSENT_ANALYTICS]: false, [CONSENT_MARKETING]: false }
438
+ env.goto(`https://site.test/?owlcc=${consentLinkHelper.encodeConsentLink(incoming)}`, 'https://platform.test/')
439
+ const store = makeConsentStore()
440
+ store.init({ silent: true, linker: { domains: DOMAINS }, geo: { cloudflare: true } })
441
+
442
+ expect(store.get().record).toMatchObject({ [CONSENT_ANALYTICS]: false, [CONSENT_ESSENTIAL]: true })
443
+ expect(consentStorageHelper.readConsent()?.auto).toBeUndefined()
444
+ expect(env.calls.length).toBe(0)
445
+ })
446
+
447
+ test('the inline fragment adopts over an automatic decision, never over an explicit one', () => {
448
+ const incoming = { [CONSENT_ANALYTICS]: false, [CONSENT_MARKETING]: false }
449
+ const script = consentLinkHelper.consentLinkerScript({ linker: { domains: DOMAINS } })
450
+
451
+ consentStorageHelper.writeConsent({ ...consentGeoHelper.automaticRecord(), auto: nowSeconds() })
452
+ env.goto(`https://site.test/?owlcc=${consentLinkHelper.encodeConsentLink(incoming)}`, 'https://platform.test/')
453
+ // eslint-disable-next-line no-new-func
454
+ new Function(script)()
455
+ expect(JSON.parse(env.store.get(CONSENT_KEY) ?? '{}')).toMatchObject({ [CONSENT_ANALYTICS]: false })
456
+ expect(JSON.parse(env.store.get(CONSENT_KEY) ?? '{}').auto).toBeUndefined()
457
+
458
+ consentStorageHelper.writeConsent({ [CONSENT_ESSENTIAL]: true, [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: true })
459
+ env.goto(`https://site.test/?owlcc=${consentLinkHelper.encodeConsentLink(incoming)}`, 'https://platform.test/')
460
+ // eslint-disable-next-line no-new-func
461
+ new Function(script)()
462
+ expect(JSON.parse(env.store.get(CONSENT_KEY) ?? '{}')).toMatchObject({ [CONSENT_ANALYTICS]: true })
463
+ })
464
+ })
465
+
466
+ describe('storage', () => {
467
+ test('clearing removes the cookie written under a cookie domain', () => {
468
+ consentStorageHelper.writeConsent({ [CONSENT_ANALYTICS]: true }, { cookieDomain: 'site.test' })
469
+ env.store.clear()
470
+ consentStorageHelper.clearConsent({ cookieDomain: 'site.test' })
471
+
472
+ expect(consentStorageHelper.readConsent()).toBeNull()
473
+ })
474
+ })