@linkforty/core 1.44.1 → 1.44.3

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 (175) hide show
  1. package/README.md +20 -588
  2. package/llms.txt +1 -1
  3. package/package.json +2 -1
  4. package/dist/index.config.test.d.ts +0 -2
  5. package/dist/index.config.test.d.ts.map +0 -1
  6. package/dist/index.config.test.js +0 -87
  7. package/dist/index.config.test.js.map +0 -1
  8. package/dist/lib/bot-detection.test.d.ts +0 -2
  9. package/dist/lib/bot-detection.test.d.ts.map +0 -1
  10. package/dist/lib/bot-detection.test.js +0 -65
  11. package/dist/lib/bot-detection.test.js.map +0 -1
  12. package/dist/lib/client-ip.test.d.ts +0 -7
  13. package/dist/lib/client-ip.test.d.ts.map +0 -1
  14. package/dist/lib/client-ip.test.js +0 -114
  15. package/dist/lib/client-ip.test.js.map +0 -1
  16. package/dist/lib/config.test.d.ts +0 -2
  17. package/dist/lib/config.test.d.ts.map +0 -1
  18. package/dist/lib/config.test.js +0 -126
  19. package/dist/lib/config.test.js.map +0 -1
  20. package/dist/lib/database.test.d.ts +0 -2
  21. package/dist/lib/database.test.d.ts.map +0 -1
  22. package/dist/lib/database.test.js +0 -180
  23. package/dist/lib/database.test.js.map +0 -1
  24. package/dist/lib/deep-link-payload.test.d.ts +0 -2
  25. package/dist/lib/deep-link-payload.test.d.ts.map +0 -1
  26. package/dist/lib/deep-link-payload.test.js +0 -202
  27. package/dist/lib/deep-link-payload.test.js.map +0 -1
  28. package/dist/lib/event-emitter.test.d.ts +0 -2
  29. package/dist/lib/event-emitter.test.d.ts.map +0 -1
  30. package/dist/lib/event-emitter.test.js +0 -162
  31. package/dist/lib/event-emitter.test.js.map +0 -1
  32. package/dist/lib/fingerprint.test.d.ts +0 -2
  33. package/dist/lib/fingerprint.test.d.ts.map +0 -1
  34. package/dist/lib/fingerprint.test.js +0 -750
  35. package/dist/lib/fingerprint.test.js.map +0 -1
  36. package/dist/lib/launchpad.test.d.ts +0 -2
  37. package/dist/lib/launchpad.test.d.ts.map +0 -1
  38. package/dist/lib/launchpad.test.js +0 -368
  39. package/dist/lib/launchpad.test.js.map +0 -1
  40. package/dist/lib/link-lookup.test.d.ts +0 -2
  41. package/dist/lib/link-lookup.test.d.ts.map +0 -1
  42. package/dist/lib/link-lookup.test.js +0 -61
  43. package/dist/lib/link-lookup.test.js.map +0 -1
  44. package/dist/lib/link-resolution-cache.test.d.ts +0 -2
  45. package/dist/lib/link-resolution-cache.test.d.ts.map +0 -1
  46. package/dist/lib/link-resolution-cache.test.js +0 -45
  47. package/dist/lib/link-resolution-cache.test.js.map +0 -1
  48. package/dist/lib/link-safety.test.d.ts +0 -2
  49. package/dist/lib/link-safety.test.d.ts.map +0 -1
  50. package/dist/lib/link-safety.test.js +0 -211
  51. package/dist/lib/link-safety.test.js.map +0 -1
  52. package/dist/lib/not-found-page.test.d.ts +0 -2
  53. package/dist/lib/not-found-page.test.d.ts.map +0 -1
  54. package/dist/lib/not-found-page.test.js +0 -151
  55. package/dist/lib/not-found-page.test.js.map +0 -1
  56. package/dist/lib/portable/cli.test.d.ts +0 -2
  57. package/dist/lib/portable/cli.test.d.ts.map +0 -1
  58. package/dist/lib/portable/cli.test.js +0 -98
  59. package/dist/lib/portable/cli.test.js.map +0 -1
  60. package/dist/lib/portable/portable.integration.test.d.ts +0 -2
  61. package/dist/lib/portable/portable.integration.test.d.ts.map +0 -1
  62. package/dist/lib/portable/portable.integration.test.js +0 -139
  63. package/dist/lib/portable/portable.integration.test.js.map +0 -1
  64. package/dist/lib/portable/registry.schema.test.d.ts +0 -2
  65. package/dist/lib/portable/registry.schema.test.d.ts.map +0 -1
  66. package/dist/lib/portable/registry.schema.test.js +0 -59
  67. package/dist/lib/portable/registry.schema.test.js.map +0 -1
  68. package/dist/lib/scanner-detection.test.d.ts +0 -2
  69. package/dist/lib/scanner-detection.test.d.ts.map +0 -1
  70. package/dist/lib/scanner-detection.test.js +0 -124
  71. package/dist/lib/scanner-detection.test.js.map +0 -1
  72. package/dist/lib/schema-guard.test.d.ts +0 -2
  73. package/dist/lib/schema-guard.test.d.ts.map +0 -1
  74. package/dist/lib/schema-guard.test.js +0 -83
  75. package/dist/lib/schema-guard.test.js.map +0 -1
  76. package/dist/lib/utils.test.d.ts +0 -2
  77. package/dist/lib/utils.test.d.ts.map +0 -1
  78. package/dist/lib/utils.test.js +0 -261
  79. package/dist/lib/utils.test.js.map +0 -1
  80. package/dist/lib/webhook.test.d.ts +0 -2
  81. package/dist/lib/webhook.test.d.ts.map +0 -1
  82. package/dist/lib/webhook.test.js +0 -340
  83. package/dist/lib/webhook.test.js.map +0 -1
  84. package/dist/routes/health.test.d.ts +0 -2
  85. package/dist/routes/health.test.d.ts.map +0 -1
  86. package/dist/routes/health.test.js +0 -82
  87. package/dist/routes/health.test.js.map +0 -1
  88. package/dist/routes/preview.test.d.ts +0 -2
  89. package/dist/routes/preview.test.d.ts.map +0 -1
  90. package/dist/routes/preview.test.js +0 -111
  91. package/dist/routes/preview.test.js.map +0 -1
  92. package/dist/routes/qr.test.d.ts +0 -2
  93. package/dist/routes/qr.test.d.ts.map +0 -1
  94. package/dist/routes/qr.test.js +0 -67
  95. package/dist/routes/qr.test.js.map +0 -1
  96. package/dist/routes/redirect.click-event.test.d.ts +0 -2
  97. package/dist/routes/redirect.click-event.test.d.ts.map +0 -1
  98. package/dist/routes/redirect.click-event.test.js +0 -85
  99. package/dist/routes/redirect.click-event.test.js.map +0 -1
  100. package/dist/routes/redirect.host-scope.integration.test.d.ts +0 -2
  101. package/dist/routes/redirect.host-scope.integration.test.d.ts.map +0 -1
  102. package/dist/routes/redirect.host-scope.integration.test.js +0 -105
  103. package/dist/routes/redirect.host-scope.integration.test.js.map +0 -1
  104. package/dist/routes/redirect.interstitial.test.d.ts +0 -2
  105. package/dist/routes/redirect.interstitial.test.d.ts.map +0 -1
  106. package/dist/routes/redirect.interstitial.test.js +0 -128
  107. package/dist/routes/redirect.interstitial.test.js.map +0 -1
  108. package/dist/routes/redirect.launchpad.test.d.ts +0 -2
  109. package/dist/routes/redirect.launchpad.test.d.ts.map +0 -1
  110. package/dist/routes/redirect.launchpad.test.js +0 -515
  111. package/dist/routes/redirect.launchpad.test.js.map +0 -1
  112. package/dist/routes/redirect.no-web-destination.test.d.ts +0 -2
  113. package/dist/routes/redirect.no-web-destination.test.d.ts.map +0 -1
  114. package/dist/routes/redirect.no-web-destination.test.js +0 -176
  115. package/dist/routes/redirect.no-web-destination.test.js.map +0 -1
  116. package/dist/routes/redirect.not-found.test.d.ts +0 -2
  117. package/dist/routes/redirect.not-found.test.d.ts.map +0 -1
  118. package/dist/routes/redirect.not-found.test.js +0 -280
  119. package/dist/routes/redirect.not-found.test.js.map +0 -1
  120. package/dist/routes/redirect.safety.test.d.ts +0 -2
  121. package/dist/routes/redirect.safety.test.d.ts.map +0 -1
  122. package/dist/routes/redirect.safety.test.js +0 -284
  123. package/dist/routes/redirect.safety.test.js.map +0 -1
  124. package/dist/routes/redirect.store-url.test.d.ts +0 -2
  125. package/dist/routes/redirect.store-url.test.d.ts.map +0 -1
  126. package/dist/routes/redirect.store-url.test.js +0 -239
  127. package/dist/routes/redirect.store-url.test.js.map +0 -1
  128. package/dist/routes/redirect.test.d.ts +0 -2
  129. package/dist/routes/redirect.test.d.ts.map +0 -1
  130. package/dist/routes/redirect.test.js +0 -158
  131. package/dist/routes/redirect.test.js.map +0 -1
  132. package/dist/routes/redirect.web-link.test.d.ts +0 -2
  133. package/dist/routes/redirect.web-link.test.d.ts.map +0 -1
  134. package/dist/routes/redirect.web-link.test.js +0 -138
  135. package/dist/routes/redirect.web-link.test.js.map +0 -1
  136. package/dist/routes/sdk-cache-bypass.test.d.ts +0 -2
  137. package/dist/routes/sdk-cache-bypass.test.d.ts.map +0 -1
  138. package/dist/routes/sdk-cache-bypass.test.js +0 -175
  139. package/dist/routes/sdk-cache-bypass.test.js.map +0 -1
  140. package/dist/routes/sdk.device-values.integration.test.d.ts +0 -2
  141. package/dist/routes/sdk.device-values.integration.test.d.ts.map +0 -1
  142. package/dist/routes/sdk.device-values.integration.test.js +0 -132
  143. package/dist/routes/sdk.device-values.integration.test.js.map +0 -1
  144. package/dist/routes/sdk.event.test.d.ts +0 -2
  145. package/dist/routes/sdk.event.test.d.ts.map +0 -1
  146. package/dist/routes/sdk.event.test.js +0 -284
  147. package/dist/routes/sdk.event.test.js.map +0 -1
  148. package/dist/routes/sdk.install-certainty.integration.test.d.ts +0 -2
  149. package/dist/routes/sdk.install-certainty.integration.test.d.ts.map +0 -1
  150. package/dist/routes/sdk.install-certainty.integration.test.js +0 -104
  151. package/dist/routes/sdk.install-certainty.integration.test.js.map +0 -1
  152. package/dist/routes/sdk.install-live-links.integration.test.d.ts +0 -2
  153. package/dist/routes/sdk.install-live-links.integration.test.d.ts.map +0 -1
  154. package/dist/routes/sdk.install-live-links.integration.test.js +0 -92
  155. package/dist/routes/sdk.install-live-links.integration.test.js.map +0 -1
  156. package/dist/routes/sdk.install-referrer.integration.test.d.ts +0 -2
  157. package/dist/routes/sdk.install-referrer.integration.test.d.ts.map +0 -1
  158. package/dist/routes/sdk.install-referrer.integration.test.js +0 -187
  159. package/dist/routes/sdk.install-referrer.integration.test.js.map +0 -1
  160. package/dist/routes/sdk.install-window-once.integration.test.d.ts +0 -2
  161. package/dist/routes/sdk.install-window-once.integration.test.d.ts.map +0 -1
  162. package/dist/routes/sdk.install-window-once.integration.test.js +0 -147
  163. package/dist/routes/sdk.install-window-once.integration.test.js.map +0 -1
  164. package/dist/routes/sdk.install.test.d.ts +0 -2
  165. package/dist/routes/sdk.install.test.d.ts.map +0 -1
  166. package/dist/routes/sdk.install.test.js +0 -99
  167. package/dist/routes/sdk.install.test.js.map +0 -1
  168. package/dist/routes/sdk.resolve-click.test.d.ts +0 -2
  169. package/dist/routes/sdk.resolve-click.test.d.ts.map +0 -1
  170. package/dist/routes/sdk.resolve-click.test.js +0 -79
  171. package/dist/routes/sdk.resolve-click.test.js.map +0 -1
  172. package/dist/routes/sdk.resolve-scope.test.d.ts +0 -2
  173. package/dist/routes/sdk.resolve-scope.test.d.ts.map +0 -1
  174. package/dist/routes/sdk.resolve-scope.test.js +0 -105
  175. package/dist/routes/sdk.resolve-scope.test.js.map +0 -1
@@ -1,750 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- // Mock the database module so tests don't require a real Postgres connection.
3
- vi.mock('./database', () => ({
4
- db: {
5
- query: vi.fn(),
6
- },
7
- }));
8
- import * as fingerprint from './fingerprint';
9
- import { db } from './database';
10
- const mockDbQuery = db.query;
11
- const baseFingerprint = {
12
- ipAddress: '24.5.10.100',
13
- userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36',
14
- timezone: 'America/Los_Angeles',
15
- language: 'en-US',
16
- screenWidth: 1080,
17
- screenHeight: 1920,
18
- platform: 'Windows',
19
- platformVersion: '10',
20
- };
21
- describe('generateFingerprintHash', () => {
22
- it('produces a deterministic 64-character SHA-256 hash', () => {
23
- const hash1 = fingerprint.generateFingerprintHash(baseFingerprint);
24
- const hash2 = fingerprint.generateFingerprintHash(baseFingerprint);
25
- expect(hash1).toHaveLength(64);
26
- expect(hash1).toBe(hash2);
27
- });
28
- it('produces different hashes for different data', () => {
29
- const other = { ...baseFingerprint, ipAddress: '10.0.0.1' };
30
- const hash1 = fingerprint.generateFingerprintHash(baseFingerprint);
31
- const hash2 = fingerprint.generateFingerprintHash(other);
32
- expect(hash1).not.toBe(hash2);
33
- });
34
- });
35
- describe('calculateConfidenceScore', () => {
36
- it('returns 0 score when nothing matches', () => {
37
- const a = { ...baseFingerprint };
38
- const b = {
39
- ...baseFingerprint,
40
- ipAddress: '10.0.0.1',
41
- userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
42
- timezone: 'Asia/Tokyo',
43
- language: 'ja-JP',
44
- screenWidth: 800,
45
- screenHeight: 600,
46
- platform: 'Macintosh',
47
- platformVersion: '11.0',
48
- };
49
- const { score, matchedFactors } = fingerprint.calculateConfidenceScore(a, b);
50
- expect(score).toBe(0);
51
- expect(matchedFactors).toEqual([]);
52
- });
53
- it('matches IP within the same /24 subnet and normalizes user agent', () => {
54
- const click = {
55
- ...baseFingerprint,
56
- ipAddress: '24.5.10.250',
57
- timezone: 'Africa/Cairo',
58
- language: 'fr-FR',
59
- screenWidth: 800,
60
- screenHeight: 600,
61
- platform: 'Linux',
62
- };
63
- const install = {
64
- ...baseFingerprint,
65
- ipAddress: '24.5.10.123',
66
- userAgent: baseFingerprint.userAgent.replace('Chrome/95.0.4638.69', 'Chrome/116.0.0.0'),
67
- timezone: 'Europe/London',
68
- language: 'de-DE',
69
- screenWidth: 1200,
70
- screenHeight: 900,
71
- platform: 'Windows',
72
- };
73
- const { score, matchedFactors } = fingerprint.calculateConfidenceScore(click, install);
74
- expect(score).toBe(70);
75
- expect(matchedFactors).toContain('ip');
76
- expect(matchedFactors).toContain('user_agent');
77
- });
78
- it('matches language by first two characters', () => {
79
- const a = {
80
- ...baseFingerprint,
81
- ipAddress: '10.0.0.1',
82
- userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
83
- timezone: 'Asia/Tokyo',
84
- screenWidth: 800,
85
- screenHeight: 600,
86
- platform: 'Macintosh',
87
- platformVersion: '11.0',
88
- language: 'en-US',
89
- };
90
- const b = {
91
- ...a,
92
- ipAddress: '172.16.0.1',
93
- userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G973F) Chrome/91.0.4472.120 Mobile Safari/537.36',
94
- timezone: 'UTC',
95
- screenWidth: 1024,
96
- screenHeight: 768,
97
- platform: 'Linux',
98
- platformVersion: '10',
99
- language: 'en-GB',
100
- };
101
- const { score, matchedFactors } = fingerprint.calculateConfidenceScore(a, b);
102
- expect(score).toBe(10);
103
- expect(matchedFactors).toEqual(['language']);
104
- });
105
- it('matches timezone and resolution', () => {
106
- const a = {
107
- ...baseFingerprint,
108
- ipAddress: '10.0.0.1',
109
- userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
110
- language: 'ja-JP',
111
- platform: 'Macintosh',
112
- platformVersion: '11.0',
113
- timezone: 'UTC',
114
- screenWidth: 100,
115
- screenHeight: 200,
116
- };
117
- const b = {
118
- ...a,
119
- ipAddress: '172.16.0.1',
120
- userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G973F) Chrome/91.0.4472.120 Mobile Safari/537.36',
121
- language: 'fr-FR',
122
- };
123
- const { score, matchedFactors } = fingerprint.calculateConfidenceScore(a, b);
124
- expect(score).toBe(20);
125
- expect(matchedFactors.sort()).toEqual(['screen', 'timezone'].sort());
126
- });
127
- });
128
- describe('isAttributableIp', () => {
129
- it('treats public IPv4 as attributable', () => {
130
- expect(fingerprint.isAttributableIp('24.5.10.100')).toBe(true);
131
- expect(fingerprint.isAttributableIp('8.8.8.8')).toBe(true);
132
- });
133
- it('rejects CGNAT (100.64.0.0/10)', () => {
134
- expect(fingerprint.isAttributableIp('100.64.0.5')).toBe(false);
135
- expect(fingerprint.isAttributableIp('100.127.255.254')).toBe(false);
136
- });
137
- it('rejects RFC1918 private, loopback and link-local', () => {
138
- expect(fingerprint.isAttributableIp('10.0.0.1')).toBe(false);
139
- expect(fingerprint.isAttributableIp('172.16.0.1')).toBe(false);
140
- expect(fingerprint.isAttributableIp('192.168.1.1')).toBe(false);
141
- expect(fingerprint.isAttributableIp('127.0.0.1')).toBe(false);
142
- expect(fingerprint.isAttributableIp('169.254.1.1')).toBe(false);
143
- });
144
- it('handles IPv6: public attributable, ULA/link-local/loopback not', () => {
145
- expect(fingerprint.isAttributableIp('2600:1700:6508:8040::1')).toBe(true);
146
- expect(fingerprint.isAttributableIp('fd00::1')).toBe(false); // ULA
147
- expect(fingerprint.isAttributableIp('fe80::1')).toBe(false); // link-local
148
- expect(fingerprint.isAttributableIp('::1')).toBe(false); // loopback
149
- });
150
- it('unwraps IPv4-mapped IPv6 before classifying', () => {
151
- expect(fingerprint.isAttributableIp('::ffff:100.64.0.5')).toBe(false);
152
- expect(fingerprint.isAttributableIp('::ffff:24.5.10.100')).toBe(true);
153
- });
154
- it('returns false for empty/garbage input', () => {
155
- expect(fingerprint.isAttributableIp('')).toBe(false);
156
- expect(fingerprint.isAttributableIp('not-an-ip')).toBe(false);
157
- });
158
- });
159
- describe('calculateConfidenceScore — shared-IP filter', () => {
160
- it('does NOT award the IP score for two devices sharing a CGNAT /24', () => {
161
- // Unrelated installs behind CGNAT collapse onto 100.64.0.x and must not match.
162
- const click = { ...baseFingerprint, ipAddress: '100.64.0.5' };
163
- const install = { ...baseFingerprint, ipAddress: '100.64.0.9' };
164
- const { score, matchedFactors } = fingerprint.calculateConfidenceScore(click, install);
165
- expect(matchedFactors).not.toContain('ip');
166
- // UA(30)+TZ(10)+lang(10)+screen(10) = 60, below the 70 threshold → no match.
167
- expect(score).toBe(60);
168
- expect(score).toBeLessThan(fingerprint.CONFIDENCE_THRESHOLD);
169
- });
170
- it('still awards the IP score for two devices sharing a public /24', () => {
171
- const click = { ...baseFingerprint, ipAddress: '24.5.10.5' };
172
- const install = { ...baseFingerprint, ipAddress: '24.5.10.9' };
173
- const { matchedFactors } = fingerprint.calculateConfidenceScore(click, install);
174
- expect(matchedFactors).toContain('ip');
175
- });
176
- });
177
- describe('calculateConfidenceScore - deferred install (SDK vs browser)', () => {
178
- // A real deferred install: the click carries a Safari user agent, the install
179
- // carries the string FingerprintCollector builds. Same device, same wifi.
180
- const safariClick = {
181
- ipAddress: '103.28.14.77',
182
- userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
183
- timezone: 'Asia/Jakarta',
184
- language: 'en-US',
185
- screenWidth: 393, // CSS pixels
186
- screenHeight: 852,
187
- platform: 'ios', // detectDevice()
188
- platformVersion: '17.5',
189
- };
190
- const sdkInstall = {
191
- ipAddress: '103.28.14.77',
192
- userAgent: 'MNC Play/1.0.0 iOS/17.5', // generateUserAgent()
193
- timezone: 'Asia/Jakarta',
194
- language: 'en_US', // Locale.current.identifier
195
- screenWidth: 1179, // native pixels (bounds * scale)
196
- screenHeight: 2556,
197
- platform: 'iOS',
198
- platformVersion: '17.5',
199
- };
200
- it('attributes an iOS install that matches its click', () => {
201
- const { score, matchedFactors } = fingerprint.calculateConfidenceScore(sdkInstall, safariClick);
202
- expect(score).toBeGreaterThanOrEqual(fingerprint.CONFIDENCE_THRESHOLD);
203
- expect(matchedFactors).toEqual(expect.arrayContaining(['ip', 'platform', 'platform_version', 'timezone', 'language']));
204
- });
205
- it('matches native-pixel screens against CSS-pixel screens', () => {
206
- const { matchedFactors } = fingerprint.calculateConfidenceScore(sdkInstall, safariClick);
207
- expect(matchedFactors).toContain('screen');
208
- });
209
- it('attributes an Android install that matches its click', () => {
210
- const chromeClick = {
211
- ...safariClick,
212
- userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
213
- platform: 'android',
214
- platformVersion: '14',
215
- screenWidth: 412,
216
- screenHeight: 915,
217
- };
218
- const androidInstall = {
219
- ...sdkInstall,
220
- userAgent: 'MNC Play/1.0.0 Android/14',
221
- platform: 'Android',
222
- platformVersion: '14',
223
- screenWidth: 1080,
224
- screenHeight: 2400,
225
- };
226
- const { score } = fingerprint.calculateConfidenceScore(androidInstall, chromeClick);
227
- expect(score).toBeGreaterThanOrEqual(fingerprint.CONFIDENCE_THRESHOLD);
228
- });
229
- it('still attributes when the click carries no screen dimensions', () => {
230
- const noScreen = { ...safariClick, screenWidth: undefined, screenHeight: undefined };
231
- const { score } = fingerprint.calculateConfidenceScore(sdkInstall, noScreen);
232
- expect(score).toBeGreaterThanOrEqual(fingerprint.CONFIDENCE_THRESHOLD);
233
- });
234
- it('disqualifies a click from a different platform', () => {
235
- const androidClick = {
236
- ...safariClick,
237
- userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
238
- platform: 'android',
239
- platformVersion: '14',
240
- };
241
- const { score, matchedFactors } = fingerprint.calculateConfidenceScore(sdkInstall, androidClick);
242
- expect(score).toBe(0);
243
- expect(matchedFactors).toEqual([]);
244
- });
245
- it('does not attribute an install seen on a different network', () => {
246
- const { score } = fingerprint.calculateConfidenceScore({ ...sdkInstall, ipAddress: '114.10.9.3' }, safariClick);
247
- expect(score).toBeLessThan(fingerprint.CONFIDENCE_THRESHOLD);
248
- });
249
- describe('Safari on iOS 26 and later, which reports iOS 18 in its user agent', () => {
250
- // Safari 26 on iOS 26.2. No timezone or screen: a plain redirect click.
251
- const frozenClick = {
252
- ipAddress: '103.28.14.77',
253
- userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1',
254
- language: 'en-US',
255
- platform: 'ios',
256
- platformVersion: '18.7',
257
- };
258
- const install26 = {
259
- ...sdkInstall,
260
- userAgent: 'MNC Play/1.0.0 iOS/26.2',
261
- platformVersion: '26.2',
262
- language: 'en-US',
263
- };
264
- it('matches the version of an SDK reporting iOS 26', () => {
265
- const { score, matchedFactors } = fingerprint.calculateConfidenceScore(install26, frozenClick);
266
- expect(matchedFactors).toEqual(['ip', 'platform', 'platform_version', 'language']);
267
- expect(score).toBe(80);
268
- });
269
- it('matches whichever side the click is on', () => {
270
- const { matchedFactors } = fingerprint.calculateConfidenceScore(frozenClick, install26);
271
- expect(matchedFactors).toContain('platform_version');
272
- });
273
- it('still matches an SDK reporting iOS 18', () => {
274
- const install18 = { ...install26, userAgent: 'MNC Play/1.0.0 iOS/18.7', platformVersion: '18.7' };
275
- const { matchedFactors } = fingerprint.calculateConfidenceScore(install18, frozenClick);
276
- expect(matchedFactors).toContain('platform_version');
277
- });
278
- it('does not match an SDK reporting an iOS version below 18', () => {
279
- const install17 = { ...install26, userAgent: 'MNC Play/1.0.0 iOS/17.5', platformVersion: '17.5' };
280
- const { matchedFactors } = fingerprint.calculateConfidenceScore(install17, frozenClick);
281
- expect(matchedFactors).not.toContain('platform_version');
282
- });
283
- it('does not let an older browser version match a newer SDK', () => {
284
- const click17 = {
285
- ...frozenClick,
286
- userAgent: safariClick.userAgent,
287
- platformVersion: '17.5',
288
- };
289
- const { matchedFactors } = fingerprint.calculateConfidenceScore(install26, click17);
290
- expect(matchedFactors).not.toContain('platform_version');
291
- });
292
- it('leaves Android versions compared exactly', () => {
293
- const chromeClick = {
294
- ...frozenClick,
295
- userAgent: 'Mozilla/5.0 (Linux; Android 18; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
296
- platform: 'android',
297
- platformVersion: '18',
298
- };
299
- const android19 = { ...install26, userAgent: 'MNC Play/1.0.0 Android/19', platform: 'Android', platformVersion: '19' };
300
- const { matchedFactors } = fingerprint.calculateConfidenceScore(android19, chromeClick);
301
- expect(matchedFactors).not.toContain('platform_version');
302
- });
303
- });
304
- it('still requires exact screen equality between two browsers', () => {
305
- const a = { ...safariClick, screenWidth: 800, screenHeight: 600 };
306
- const b = { ...safariClick, screenWidth: 1200, screenHeight: 900 };
307
- const { matchedFactors } = fingerprint.calculateConfidenceScore(a, b);
308
- expect(matchedFactors).not.toContain('screen');
309
- });
310
- });
311
- describe('matchInstallToClick', () => {
312
- beforeEach(() => {
313
- vi.useFakeTimers();
314
- vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
315
- mockDbQuery.mockReset();
316
- });
317
- afterEach(() => {
318
- vi.useRealTimers();
319
- });
320
- it('returns null when there are no click rows', async () => {
321
- mockDbQuery.mockResolvedValueOnce({ rows: [] });
322
- const result = await fingerprint.matchInstallToClick(baseFingerprint);
323
- expect(result).toBeNull();
324
- });
325
- it('returns the best match above the confidence threshold', async () => {
326
- const clickTime = new Date('2024-12-31T23:00:00Z');
327
- // First row: only IP match (score 40)
328
- const rowA = {
329
- click_id: 'click-a',
330
- link_id: 'link-a',
331
- clicked_at: clickTime.toISOString(),
332
- attribution_window_hours: 24,
333
- ip_address: '24.5.10.200',
334
- user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
335
- timezone: 'Asia/Tokyo',
336
- language: 'ja-JP',
337
- screen_width: 720,
338
- screen_height: 1280,
339
- platform: 'Macintosh',
340
- platform_version: '11.0',
341
- };
342
- // Second row: IP + user agent + timezone + language + screen (score 100)
343
- const rowB = {
344
- click_id: 'click-b',
345
- link_id: 'link-b',
346
- clicked_at: clickTime.toISOString(),
347
- attribution_window_hours: 24,
348
- ip_address: '24.5.10.250',
349
- user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36',
350
- timezone: 'America/Los_Angeles',
351
- language: 'en-US',
352
- screen_width: 1080,
353
- screen_height: 1920,
354
- platform: 'Windows',
355
- platform_version: '10',
356
- };
357
- mockDbQuery.mockResolvedValueOnce({ rows: [rowA, rowB] });
358
- const installFingerprint = {
359
- ...baseFingerprint,
360
- ipAddress: '24.5.10.123',
361
- userAgent: baseFingerprint.userAgent.replace('Chrome/95.0.4638.69', 'Chrome/116.0.0.0'),
362
- };
363
- const result = await fingerprint.matchInstallToClick(installFingerprint);
364
- expect(result).not.toBeNull();
365
- expect(result?.clickId).toBe('click-b');
366
- expect(result?.confidenceScore).toBe(100);
367
- expect(result?.matchedFactors).toEqual(expect.arrayContaining(['ip', 'user_agent', 'timezone', 'language', 'screen']));
368
- });
369
- it("applies each link's window, in minutes when set, on the database clock", async () => {
370
- mockDbQuery.mockResolvedValueOnce({ rows: [] });
371
- await fingerprint.matchInstallToClick(baseFingerprint);
372
- const [sql] = mockDbQuery.mock.calls[0];
373
- expect(sql).toMatch(/ce\.clicked_at >= NOW\(\) - make_interval\(mins => COALESCE\(\s*l\.attribution_window_minutes,/);
374
- expect(sql).toMatch(/COALESCE\(NULLIF\(l\.attribution_window_hours, 0\), 168\) \* 60/);
375
- });
376
- it('skips a matched click on a link that hands a click to one install only', async () => {
377
- mockDbQuery.mockResolvedValueOnce({ rows: [] });
378
- await fingerprint.matchInstallToClick(baseFingerprint);
379
- expect(mockDbQuery.mock.calls[0][0]).toMatch(/AND \(l\.one_install_per_click = false OR ce\.install_matched_at IS NULL\)/);
380
- });
381
- });
382
- describe('attributionNetwork', () => {
383
- it('is the /24 for IPv4', () => {
384
- expect(fingerprint.attributionNetwork('24.5.10.100')).toBe('24.5.10.0/24');
385
- });
386
- it('treats an IPv4-mapped IPv6 address as its IPv4 address', () => {
387
- expect(fingerprint.attributionNetwork('::ffff:24.5.10.100')).toBe('24.5.10.0/24');
388
- });
389
- it('is the /64 for IPv6, however the address is spelled', () => {
390
- const expected = '2001:db8:0:0::/64';
391
- expect(fingerprint.attributionNetwork('2001:db8::1')).toBe(expected);
392
- expect(fingerprint.attributionNetwork('2001:0DB8:0000:0000:0000:0000:0000:0002')).toBe(expected);
393
- expect(fingerprint.attributionNetwork('2001:db8:0:0:abcd::7')).toBe(expected);
394
- });
395
- it('expands an embedded IPv4 tail', () => {
396
- expect(fingerprint.attributionNetwork('64:ff9b::1.2.3.4')).toBe('64:ff9b:0:0::/64');
397
- });
398
- it('is null for anything unparseable', () => {
399
- expect(fingerprint.attributionNetwork('')).toBeNull();
400
- expect(fingerprint.attributionNetwork('not-an-ip')).toBeNull();
401
- expect(fingerprint.attributionNetwork('1.2.3')).toBeNull();
402
- expect(fingerprint.attributionNetwork('2001::db8::1')).toBeNull();
403
- expect(fingerprint.attributionNetwork('1:2:3:4:5:6:7:8:9')).toBeNull();
404
- });
405
- });
406
- describe('calculateConfidenceScore — IPv6 networks', () => {
407
- it('matches two spellings of addresses in the same /64', () => {
408
- const { matchedFactors } = fingerprint.calculateConfidenceScore({ ...baseFingerprint, ipAddress: '2001:db8:1:2::5' }, { ...baseFingerprint, ipAddress: '2001:0db8:0001:0002:ffff:0:0:9' });
409
- expect(matchedFactors).toContain('ip');
410
- });
411
- it('does not match addresses in different /64s', () => {
412
- const { matchedFactors } = fingerprint.calculateConfidenceScore({ ...baseFingerprint, ipAddress: '2001:db8:1:2::5' }, { ...baseFingerprint, ipAddress: '2001:db8:1:3::5' });
413
- expect(matchedFactors).not.toContain('ip');
414
- });
415
- });
416
- describe('matchInstallToClick — candidate selection', () => {
417
- beforeEach(() => {
418
- mockDbQuery.mockReset();
419
- });
420
- it('cannot reach the threshold without the IP signal, which is what makes network filtering safe', () => {
421
- expect(fingerprint.MAX_SCORE_WITHOUT_IP).toBeLessThan(fingerprint.CONFIDENCE_THRESHOLD);
422
- // A candidate matching on everything but IP, scored for real.
423
- const { score } = fingerprint.calculateConfidenceScore(baseFingerprint, {
424
- ...baseFingerprint,
425
- ipAddress: '31.13.64.1',
426
- });
427
- expect(score).toBe(fingerprint.MAX_SCORE_WITHOUT_IP);
428
- });
429
- it('selects only clicks from the install network, newest first', async () => {
430
- mockDbQuery.mockResolvedValueOnce({ rows: [] });
431
- await fingerprint.matchInstallToClick(baseFingerprint);
432
- const [sql, params] = mockDbQuery.mock.calls[0];
433
- expect(sql).toMatch(/df\.ip_address <<= \$1::cidr/);
434
- expect(sql).toMatch(/ORDER BY ce\.clicked_at DESC/);
435
- expect(sql).not.toMatch(/organization_id/);
436
- expect(params).toEqual(['24.5.10.0/24']);
437
- });
438
- it('limits candidates to the scoped organization', async () => {
439
- mockDbQuery.mockResolvedValueOnce({ rows: [] });
440
- await fingerprint.matchInstallToClick(baseFingerprint, undefined, { organizationId: 'org-1' });
441
- const [sql, params] = mockDbQuery.mock.calls[0];
442
- expect(sql).toMatch(/AND l\.organization_id = \$2/);
443
- expect(params).toEqual(['24.5.10.0/24', 'org-1']);
444
- });
445
- it('leaves out the clicks it is told to skip', async () => {
446
- mockDbQuery.mockResolvedValueOnce({ rows: [] });
447
- await fingerprint.matchInstallToClick(baseFingerprint, undefined, {}, ['click-1']);
448
- const [sql, params] = mockDbQuery.mock.calls[0];
449
- expect(sql).toMatch(/AND ce\.id <> ALL\(\$2::uuid\[\]\)/);
450
- expect(params).toEqual(['24.5.10.0/24', ['click-1']]);
451
- });
452
- it('matches nothing, without querying, when the scope says so', async () => {
453
- expect(await fingerprint.matchInstallToClick(baseFingerprint, undefined, { matchNothing: true })).toBeNull();
454
- expect(mockDbQuery).not.toHaveBeenCalled();
455
- });
456
- it('does not query at all for an install from a shared address', async () => {
457
- for (const ipAddress of ['100.64.1.2', '10.0.0.5', '192.168.1.20', '', 'garbage']) {
458
- expect(await fingerprint.matchInstallToClick({ ...baseFingerprint, ipAddress })).toBeNull();
459
- }
460
- expect(mockDbQuery).not.toHaveBeenCalled();
461
- });
462
- });
463
- describe('recordInstallEvent', () => {
464
- beforeEach(() => {
465
- mockDbQuery.mockReset();
466
- });
467
- it('inserts an install event and returns the install id when no match is found', async () => {
468
- // matchInstallToClick is invoked internally by recordInstallEvent.
469
- // The first db query is used to find click events. Return an empty list to force a null match.
470
- mockDbQuery.mockResolvedValueOnce({ rows: [] });
471
- mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-123', deep_link_data: {} }] });
472
- const result = await fingerprint.recordInstallEvent(baseFingerprint, 'device-1');
473
- expect(result.installId).toBe('install-123');
474
- expect(result.match).toBeNull();
475
- // Null, not `{}` — an SDK decodes this field into a model with a required
476
- // short code, so an empty object fails to decode the whole response.
477
- expect(result.deepLinkData).toBeNull();
478
- expect(mockDbQuery).toHaveBeenCalledTimes(2);
479
- expect(mockDbQuery).toHaveBeenLastCalledWith(expect.any(String), expect.arrayContaining([null, null, expect.any(String), null, expect.any(String), expect.any(String), null, null, null, null, null, null, null, 'device-1', null]));
480
- });
481
- it('forwards sdk name + version as the last two positional params of the install insert (guards column misalignment)', async () => {
482
- mockDbQuery.mockResolvedValueOnce({ rows: [] }); // matchInstallToClick -> no match
483
- mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-456', deep_link_data: {} }] });
484
- await fingerprint.recordInstallEvent(baseFingerprint, 'device-1', undefined, {
485
- name: 'react-native',
486
- version: '1.4.0',
487
- });
488
- const [sql, params] = mockDbQuery.mock.calls[1];
489
- expect(sql).toMatch(/INSERT INTO install_events/);
490
- expect(sql).toMatch(/sdk_name/);
491
- expect(sql).toMatch(/sdk_version/);
492
- // sdk_name/$16, sdk_version/$17, then attribution_method/$18, matched_factors/$19, competing_links/$20
493
- expect(params).toHaveLength(20);
494
- expect(params[15]).toBe('react-native'); // sdk_name
495
- expect(params[16]).toBe('1.4.0'); // sdk_version
496
- expect(params[17]).toBe('none'); // attribution_method (no fingerprint match)
497
- expect(params[18]).toBeNull(); // matched_factors (no match)
498
- expect(params[19]).toBeNull(); // competing_links (no match)
499
- });
500
- it('stores null sdk on the install row when the metadata is absent or empty', async () => {
501
- mockDbQuery.mockResolvedValueOnce({ rows: [] });
502
- mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-789', deep_link_data: {} }] });
503
- await fingerprint.recordInstallEvent(baseFingerprint, 'device-1', undefined, { name: '', version: undefined });
504
- const params = mockDbQuery.mock.calls[1][1];
505
- expect(params[15]).toBeNull(); // sdk_name '' -> null
506
- expect(params[16]).toBeNull(); // sdk_version undefined -> null
507
- });
508
- it('forwards the match scope to the click query', async () => {
509
- mockDbQuery.mockResolvedValueOnce({ rows: [] });
510
- mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-scope', deep_link_data: {} }] });
511
- await fingerprint.recordInstallEvent(baseFingerprint, undefined, undefined, undefined, {
512
- organizationId: 'org-9',
513
- });
514
- expect(mockDbQuery.mock.calls[0][1]).toContain('org-9');
515
- });
516
- });
517
- describe('matchInstallToClick — competing links', () => {
518
- const row = (clickId, linkId, overrides = {}) => ({
519
- click_id: clickId,
520
- link_id: linkId,
521
- clicked_at: new Date().toISOString(),
522
- link_params: null,
523
- one_install_per_click: false,
524
- ip_address: baseFingerprint.ipAddress,
525
- user_agent: baseFingerprint.userAgent,
526
- timezone: baseFingerprint.timezone,
527
- language: baseFingerprint.language,
528
- screen_width: baseFingerprint.screenWidth,
529
- screen_height: baseFingerprint.screenHeight,
530
- platform: baseFingerprint.platform,
531
- platform_version: baseFingerprint.platformVersion,
532
- ...overrides,
533
- });
534
- beforeEach(() => {
535
- mockDbQuery.mockReset();
536
- });
537
- it('is 0 when only one link has a qualifying click', async () => {
538
- mockDbQuery.mockResolvedValueOnce({ rows: [row('c1', 'link-a'), row('c2', 'link-a')] });
539
- expect((await fingerprint.matchInstallToClick(baseFingerprint))?.competingLinks).toBe(0);
540
- });
541
- it('counts the other links with a qualifying click', async () => {
542
- mockDbQuery.mockResolvedValueOnce({
543
- rows: [row('c1', 'link-a'), row('c2', 'link-b'), row('c3', 'link-c'), row('c4', 'link-b')],
544
- });
545
- const match = await fingerprint.matchInstallToClick(baseFingerprint);
546
- expect(match?.linkId).toBe('link-a');
547
- expect(match?.competingLinks).toBe(2);
548
- });
549
- it('leaves out clicks below the threshold', async () => {
550
- // Network only: below the threshold.
551
- const weak = row('c2', 'link-b', {
552
- user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
553
- timezone: 'Asia/Tokyo',
554
- language: 'ja-JP',
555
- screen_width: 720,
556
- screen_height: 1280,
557
- });
558
- mockDbQuery.mockResolvedValueOnce({ rows: [row('c1', 'link-a'), weak] });
559
- expect((await fingerprint.matchInstallToClick(baseFingerprint))?.competingLinks).toBe(0);
560
- });
561
- });
562
- describe('recordInstallEvent — matching a click once', () => {
563
- const clickRow = (clickId, oneInstallPerClick) => ({
564
- click_id: clickId,
565
- link_id: 'link-1',
566
- clicked_at: new Date().toISOString(),
567
- link_params: null,
568
- one_install_per_click: oneInstallPerClick,
569
- ip_address: baseFingerprint.ipAddress,
570
- user_agent: baseFingerprint.userAgent,
571
- timezone: baseFingerprint.timezone,
572
- language: baseFingerprint.language,
573
- screen_width: baseFingerprint.screenWidth,
574
- screen_height: baseFingerprint.screenHeight,
575
- platform: baseFingerprint.platform,
576
- platform_version: baseFingerprint.platformVersion,
577
- });
578
- beforeEach(() => {
579
- mockDbQuery.mockReset();
580
- mockDbQuery.mockResolvedValue({ rows: [], rowCount: 0 });
581
- });
582
- it('records the click as matched', async () => {
583
- mockDbQuery.mockResolvedValueOnce({ rows: [clickRow('click-1', false)] });
584
- mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'click-1' }], rowCount: 1 });
585
- mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-1', deep_link_data: {} }] });
586
- const result = await fingerprint.recordInstallEvent(baseFingerprint);
587
- expect(result.match?.clickId).toBe('click-1');
588
- expect(result.attributionMethod).toBe('fingerprint');
589
- expect(mockDbQuery.mock.calls[2][1][19]).toBe(0); // competing_links
590
- const [sql, params] = mockDbQuery.mock.calls[1];
591
- expect(sql).toMatch(/UPDATE click_events SET install_matched_at = NOW\(\)\s+WHERE id = \$1 AND install_matched_at IS NULL/);
592
- expect(params).toEqual(['click-1']);
593
- });
594
- it('still matches a click another install matched first, when the link allows it', async () => {
595
- mockDbQuery.mockResolvedValueOnce({ rows: [clickRow('click-1', false)] });
596
- mockDbQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 });
597
- mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-2', deep_link_data: {} }] });
598
- const result = await fingerprint.recordInstallEvent(baseFingerprint);
599
- expect(result.match?.clickId).toBe('click-1');
600
- });
601
- it('looks again without a one-install click that another install took first', async () => {
602
- mockDbQuery.mockResolvedValueOnce({ rows: [clickRow('click-1', true)] });
603
- mockDbQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 });
604
- mockDbQuery.mockResolvedValueOnce({ rows: [] });
605
- mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-3', deep_link_data: {} }] });
606
- const result = await fingerprint.recordInstallEvent(baseFingerprint);
607
- expect(result.match).toBeNull();
608
- expect(result.installId).toBe('install-3');
609
- expect(mockDbQuery.mock.calls[2][1]).toEqual(['24.5.10.0/24', ['click-1']]);
610
- });
611
- });
612
- describe('normalizeLanguageTag', () => {
613
- it.each([
614
- ['en-US', 'en-US'],
615
- ['en', 'en'],
616
- ['EN-us', 'en-US'],
617
- ['es-419', 'es-419'],
618
- // Android toLanguageTag() with regional preferences
619
- ['en-ES-u-fw-mon-mu-celsius', 'en-ES'],
620
- ['de-DE-u-mu-fahrenhe', 'de-DE'],
621
- // iOS Locale.identifier
622
- ['en_US', 'en-US'],
623
- ['en_GB@rg=eszzzz', 'en-GB'],
624
- ['en_US@calendar=japanese', 'en-US'],
625
- ['en@rg=eszzzz', 'en'],
626
- // Script subtags
627
- ['zh-Hans-CN', 'zh-CN'],
628
- ['zh_Hant_TW', 'zh-TW'],
629
- ['zh-Hans', 'zh'],
630
- ['az-Latn-AZ', 'az-AZ'],
631
- // POSIX
632
- ['en_US.UTF-8', 'en-US'],
633
- // Three-letter language, variant subtag, private use
634
- ['fil-PH', 'fil-PH'],
635
- ['ca-ES-valencia', 'ca-ES'],
636
- ['en-x-private', 'en'],
637
- [' fr-FR ', 'fr-FR'],
638
- ])('%s → %s', (input, expected) => {
639
- expect(fingerprint.normalizeLanguageTag(input)).toBe(expected);
640
- });
641
- it.each([['*'], [''], ['1234'], ['english-US'], ['-US']])('rejects %j', (input) => {
642
- expect(fingerprint.normalizeLanguageTag(input)).toBeUndefined();
643
- });
644
- it('rejects non-strings', () => {
645
- expect(fingerprint.normalizeLanguageTag(undefined)).toBeUndefined();
646
- expect(fingerprint.normalizeLanguageTag(['en-US'])).toBeUndefined();
647
- expect(fingerprint.normalizeLanguageTag(42)).toBeUndefined();
648
- });
649
- it('never exceeds the 10-character column', () => {
650
- const long = 'en-ES-u-fw-mon-mu-celsius-x-' + 'a'.repeat(200);
651
- expect(fingerprint.normalizeLanguageTag(long).length).toBeLessThanOrEqual(10);
652
- });
653
- });
654
- describe('normalizeFingerprintData', () => {
655
- it('leaves an ordinary fingerprint unchanged', () => {
656
- expect(fingerprint.normalizeFingerprintData(baseFingerprint)).toEqual(baseFingerprint);
657
- });
658
- it('bounds every text field to its column and drops unusable numbers', () => {
659
- const result = fingerprint.normalizeFingerprintData({
660
- ...baseFingerprint,
661
- timezone: 'T'.repeat(150),
662
- language: 'en-ES-u-fw-mon-mu-celsius',
663
- platform: 'P'.repeat(80),
664
- platformVersion: 'V'.repeat(80),
665
- screenWidth: 1080.6,
666
- screenHeight: 99999999999,
667
- });
668
- expect(result.timezone).toHaveLength(100);
669
- expect(result.language).toBe('en-ES');
670
- expect(result.platform).toHaveLength(50);
671
- expect(result.platformVersion).toHaveLength(50);
672
- expect(result.screenWidth).toBe(1081);
673
- expect(result.screenHeight).toBeUndefined();
674
- });
675
- it('drops query values that arrived as arrays', () => {
676
- const result = fingerprint.normalizeFingerprintData({
677
- ...baseFingerprint,
678
- timezone: ['UTC', 'UTC'],
679
- language: ['en', 'fr'],
680
- });
681
- expect(result.timezone).toBeUndefined();
682
- expect(result.language).toBeUndefined();
683
- });
684
- });
685
- describe('storage of fingerprint fields', () => {
686
- beforeEach(() => {
687
- mockDbQuery.mockReset();
688
- });
689
- it('stores a click fingerprint with the language reduced to language-region', async () => {
690
- mockDbQuery.mockResolvedValueOnce({ rows: [] });
691
- await fingerprint.storeFingerprintForClick('click-1', {
692
- ...baseFingerprint,
693
- language: 'en-ES-u-fw-mon-mu-celsius',
694
- });
695
- const params = mockDbQuery.mock.calls[0][1];
696
- expect(params[5]).toBe('en-ES'); // language/$6
697
- expect(params[1]).toBe(fingerprint.generateFingerprintHash({ ...baseFingerprint, language: 'en-ES' }));
698
- });
699
- it('records an install whose locale carries extensions and keywords', async () => {
700
- mockDbQuery.mockResolvedValueOnce({ rows: [] }); // matchInstallToClick -> no match
701
- mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-locale', deep_link_data: {} }] });
702
- await fingerprint.recordInstallEvent({ ...baseFingerprint, language: 'en_GB@rg=eszzzz', screenWidth: 393.5 }, 'd'.repeat(400), 12.4, { name: 'n'.repeat(80), version: ' 2.0.0 ' });
703
- const params = mockDbQuery.mock.calls[1][1];
704
- expect(params[4]).toBe(12); // attribution_window_hours/$5
705
- expect(params[8]).toBe('en-GB'); // language/$9
706
- expect(params[9]).toBe(394); // screen_width/$10
707
- expect(params[13]).toHaveLength(255); // device_id/$14
708
- expect(params[15]).toHaveLength(50); // sdk_name/$16
709
- expect(params[16]).toBe('2.0.0'); // sdk_version/$17
710
- });
711
- it('falls back to the default window for an unusable attribution window', async () => {
712
- mockDbQuery.mockResolvedValueOnce({ rows: [] });
713
- mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-window', deep_link_data: {} }] });
714
- await fingerprint.recordInstallEvent(baseFingerprint, undefined, -5);
715
- expect(mockDbQuery.mock.calls[1][1][4]).toBe(fingerprint.DEFAULT_ATTRIBUTION_WINDOW_HOURS);
716
- });
717
- });
718
- describe('clickIdFromInstallReferrer', () => {
719
- const CLICK = '11111111-2222-4333-8444-555555555555';
720
- it('reads the click id from a Play install referrer', () => {
721
- expect(fingerprint.clickIdFromInstallReferrer(`lf_click=${CLICK}`)).toBe(CLICK);
722
- expect(fingerprint.clickIdFromInstallReferrer(`utm_source=poster&lf_click=${CLICK}&utm_medium=print`)).toBe(CLICK);
723
- });
724
- it('reads a referrer passed on still encoded, or inside the Play URL', () => {
725
- expect(fingerprint.clickIdFromInstallReferrer(`lf_click%3D${CLICK}%26utm_source%3Dposter`)).toBe(CLICK);
726
- expect(fingerprint.clickIdFromInstallReferrer(`lf_click%253D${CLICK}`)).toBe(CLICK);
727
- expect(fingerprint.clickIdFromInstallReferrer(`https://play.google.com/store/apps/details?id=com.example.app&referrer=lf_click%3D${CLICK}`)).toBe(CLICK);
728
- expect(fingerprint.clickIdFromInstallReferrer(`market://details?id=com.example.app&referrer=lf_click%3D${CLICK}`)).toBe(CLICK);
729
- expect(fingerprint.clickIdFromInstallReferrer(` lf_click=${CLICK} `)).toBe(CLICK);
730
- });
731
- it('returns null when there is no usable click id', () => {
732
- for (const referrer of [
733
- undefined,
734
- null,
735
- 42,
736
- '',
737
- 'utm_source=google-play&utm_medium=organic',
738
- 'lf_click=not-a-uuid',
739
- `lf_click=${CLICK}'--`,
740
- `lf_click=${CLICK}&${'x'.repeat(1100)}`,
741
- 'utm_source%3Dgoogle-play%26utm_medium%3Dorganic',
742
- '%E0%A4%A',
743
- 'https://play.google.com/store/apps/details?id=com.example.app',
744
- 'not a url: lf_click',
745
- ]) {
746
- expect(fingerprint.clickIdFromInstallReferrer(referrer)).toBeNull();
747
- }
748
- });
749
- });
750
- //# sourceMappingURL=fingerprint.test.js.map