@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.
- package/README.md +20 -588
- package/llms.txt +1 -1
- package/package.json +2 -1
- package/dist/index.config.test.d.ts +0 -2
- package/dist/index.config.test.d.ts.map +0 -1
- package/dist/index.config.test.js +0 -87
- package/dist/index.config.test.js.map +0 -1
- package/dist/lib/bot-detection.test.d.ts +0 -2
- package/dist/lib/bot-detection.test.d.ts.map +0 -1
- package/dist/lib/bot-detection.test.js +0 -65
- package/dist/lib/bot-detection.test.js.map +0 -1
- package/dist/lib/client-ip.test.d.ts +0 -7
- package/dist/lib/client-ip.test.d.ts.map +0 -1
- package/dist/lib/client-ip.test.js +0 -114
- package/dist/lib/client-ip.test.js.map +0 -1
- package/dist/lib/config.test.d.ts +0 -2
- package/dist/lib/config.test.d.ts.map +0 -1
- package/dist/lib/config.test.js +0 -126
- package/dist/lib/config.test.js.map +0 -1
- package/dist/lib/database.test.d.ts +0 -2
- package/dist/lib/database.test.d.ts.map +0 -1
- package/dist/lib/database.test.js +0 -180
- package/dist/lib/database.test.js.map +0 -1
- package/dist/lib/deep-link-payload.test.d.ts +0 -2
- package/dist/lib/deep-link-payload.test.d.ts.map +0 -1
- package/dist/lib/deep-link-payload.test.js +0 -202
- package/dist/lib/deep-link-payload.test.js.map +0 -1
- package/dist/lib/event-emitter.test.d.ts +0 -2
- package/dist/lib/event-emitter.test.d.ts.map +0 -1
- package/dist/lib/event-emitter.test.js +0 -162
- package/dist/lib/event-emitter.test.js.map +0 -1
- package/dist/lib/fingerprint.test.d.ts +0 -2
- package/dist/lib/fingerprint.test.d.ts.map +0 -1
- package/dist/lib/fingerprint.test.js +0 -750
- package/dist/lib/fingerprint.test.js.map +0 -1
- package/dist/lib/launchpad.test.d.ts +0 -2
- package/dist/lib/launchpad.test.d.ts.map +0 -1
- package/dist/lib/launchpad.test.js +0 -368
- package/dist/lib/launchpad.test.js.map +0 -1
- package/dist/lib/link-lookup.test.d.ts +0 -2
- package/dist/lib/link-lookup.test.d.ts.map +0 -1
- package/dist/lib/link-lookup.test.js +0 -61
- package/dist/lib/link-lookup.test.js.map +0 -1
- package/dist/lib/link-resolution-cache.test.d.ts +0 -2
- package/dist/lib/link-resolution-cache.test.d.ts.map +0 -1
- package/dist/lib/link-resolution-cache.test.js +0 -45
- package/dist/lib/link-resolution-cache.test.js.map +0 -1
- package/dist/lib/link-safety.test.d.ts +0 -2
- package/dist/lib/link-safety.test.d.ts.map +0 -1
- package/dist/lib/link-safety.test.js +0 -211
- package/dist/lib/link-safety.test.js.map +0 -1
- package/dist/lib/not-found-page.test.d.ts +0 -2
- package/dist/lib/not-found-page.test.d.ts.map +0 -1
- package/dist/lib/not-found-page.test.js +0 -151
- package/dist/lib/not-found-page.test.js.map +0 -1
- package/dist/lib/portable/cli.test.d.ts +0 -2
- package/dist/lib/portable/cli.test.d.ts.map +0 -1
- package/dist/lib/portable/cli.test.js +0 -98
- package/dist/lib/portable/cli.test.js.map +0 -1
- package/dist/lib/portable/portable.integration.test.d.ts +0 -2
- package/dist/lib/portable/portable.integration.test.d.ts.map +0 -1
- package/dist/lib/portable/portable.integration.test.js +0 -139
- package/dist/lib/portable/portable.integration.test.js.map +0 -1
- package/dist/lib/portable/registry.schema.test.d.ts +0 -2
- package/dist/lib/portable/registry.schema.test.d.ts.map +0 -1
- package/dist/lib/portable/registry.schema.test.js +0 -59
- package/dist/lib/portable/registry.schema.test.js.map +0 -1
- package/dist/lib/scanner-detection.test.d.ts +0 -2
- package/dist/lib/scanner-detection.test.d.ts.map +0 -1
- package/dist/lib/scanner-detection.test.js +0 -124
- package/dist/lib/scanner-detection.test.js.map +0 -1
- package/dist/lib/schema-guard.test.d.ts +0 -2
- package/dist/lib/schema-guard.test.d.ts.map +0 -1
- package/dist/lib/schema-guard.test.js +0 -83
- package/dist/lib/schema-guard.test.js.map +0 -1
- package/dist/lib/utils.test.d.ts +0 -2
- package/dist/lib/utils.test.d.ts.map +0 -1
- package/dist/lib/utils.test.js +0 -261
- package/dist/lib/utils.test.js.map +0 -1
- package/dist/lib/webhook.test.d.ts +0 -2
- package/dist/lib/webhook.test.d.ts.map +0 -1
- package/dist/lib/webhook.test.js +0 -340
- package/dist/lib/webhook.test.js.map +0 -1
- package/dist/routes/health.test.d.ts +0 -2
- package/dist/routes/health.test.d.ts.map +0 -1
- package/dist/routes/health.test.js +0 -82
- package/dist/routes/health.test.js.map +0 -1
- package/dist/routes/preview.test.d.ts +0 -2
- package/dist/routes/preview.test.d.ts.map +0 -1
- package/dist/routes/preview.test.js +0 -111
- package/dist/routes/preview.test.js.map +0 -1
- package/dist/routes/qr.test.d.ts +0 -2
- package/dist/routes/qr.test.d.ts.map +0 -1
- package/dist/routes/qr.test.js +0 -67
- package/dist/routes/qr.test.js.map +0 -1
- package/dist/routes/redirect.click-event.test.d.ts +0 -2
- package/dist/routes/redirect.click-event.test.d.ts.map +0 -1
- package/dist/routes/redirect.click-event.test.js +0 -85
- package/dist/routes/redirect.click-event.test.js.map +0 -1
- package/dist/routes/redirect.host-scope.integration.test.d.ts +0 -2
- package/dist/routes/redirect.host-scope.integration.test.d.ts.map +0 -1
- package/dist/routes/redirect.host-scope.integration.test.js +0 -105
- package/dist/routes/redirect.host-scope.integration.test.js.map +0 -1
- package/dist/routes/redirect.interstitial.test.d.ts +0 -2
- package/dist/routes/redirect.interstitial.test.d.ts.map +0 -1
- package/dist/routes/redirect.interstitial.test.js +0 -128
- package/dist/routes/redirect.interstitial.test.js.map +0 -1
- package/dist/routes/redirect.launchpad.test.d.ts +0 -2
- package/dist/routes/redirect.launchpad.test.d.ts.map +0 -1
- package/dist/routes/redirect.launchpad.test.js +0 -515
- package/dist/routes/redirect.launchpad.test.js.map +0 -1
- package/dist/routes/redirect.no-web-destination.test.d.ts +0 -2
- package/dist/routes/redirect.no-web-destination.test.d.ts.map +0 -1
- package/dist/routes/redirect.no-web-destination.test.js +0 -176
- package/dist/routes/redirect.no-web-destination.test.js.map +0 -1
- package/dist/routes/redirect.not-found.test.d.ts +0 -2
- package/dist/routes/redirect.not-found.test.d.ts.map +0 -1
- package/dist/routes/redirect.not-found.test.js +0 -280
- package/dist/routes/redirect.not-found.test.js.map +0 -1
- package/dist/routes/redirect.safety.test.d.ts +0 -2
- package/dist/routes/redirect.safety.test.d.ts.map +0 -1
- package/dist/routes/redirect.safety.test.js +0 -284
- package/dist/routes/redirect.safety.test.js.map +0 -1
- package/dist/routes/redirect.store-url.test.d.ts +0 -2
- package/dist/routes/redirect.store-url.test.d.ts.map +0 -1
- package/dist/routes/redirect.store-url.test.js +0 -239
- package/dist/routes/redirect.store-url.test.js.map +0 -1
- package/dist/routes/redirect.test.d.ts +0 -2
- package/dist/routes/redirect.test.d.ts.map +0 -1
- package/dist/routes/redirect.test.js +0 -158
- package/dist/routes/redirect.test.js.map +0 -1
- package/dist/routes/redirect.web-link.test.d.ts +0 -2
- package/dist/routes/redirect.web-link.test.d.ts.map +0 -1
- package/dist/routes/redirect.web-link.test.js +0 -138
- package/dist/routes/redirect.web-link.test.js.map +0 -1
- package/dist/routes/sdk-cache-bypass.test.d.ts +0 -2
- package/dist/routes/sdk-cache-bypass.test.d.ts.map +0 -1
- package/dist/routes/sdk-cache-bypass.test.js +0 -175
- package/dist/routes/sdk-cache-bypass.test.js.map +0 -1
- package/dist/routes/sdk.device-values.integration.test.d.ts +0 -2
- package/dist/routes/sdk.device-values.integration.test.d.ts.map +0 -1
- package/dist/routes/sdk.device-values.integration.test.js +0 -132
- package/dist/routes/sdk.device-values.integration.test.js.map +0 -1
- package/dist/routes/sdk.event.test.d.ts +0 -2
- package/dist/routes/sdk.event.test.d.ts.map +0 -1
- package/dist/routes/sdk.event.test.js +0 -284
- package/dist/routes/sdk.event.test.js.map +0 -1
- package/dist/routes/sdk.install-certainty.integration.test.d.ts +0 -2
- package/dist/routes/sdk.install-certainty.integration.test.d.ts.map +0 -1
- package/dist/routes/sdk.install-certainty.integration.test.js +0 -104
- package/dist/routes/sdk.install-certainty.integration.test.js.map +0 -1
- package/dist/routes/sdk.install-live-links.integration.test.d.ts +0 -2
- package/dist/routes/sdk.install-live-links.integration.test.d.ts.map +0 -1
- package/dist/routes/sdk.install-live-links.integration.test.js +0 -92
- package/dist/routes/sdk.install-live-links.integration.test.js.map +0 -1
- package/dist/routes/sdk.install-referrer.integration.test.d.ts +0 -2
- package/dist/routes/sdk.install-referrer.integration.test.d.ts.map +0 -1
- package/dist/routes/sdk.install-referrer.integration.test.js +0 -187
- package/dist/routes/sdk.install-referrer.integration.test.js.map +0 -1
- package/dist/routes/sdk.install-window-once.integration.test.d.ts +0 -2
- package/dist/routes/sdk.install-window-once.integration.test.d.ts.map +0 -1
- package/dist/routes/sdk.install-window-once.integration.test.js +0 -147
- package/dist/routes/sdk.install-window-once.integration.test.js.map +0 -1
- package/dist/routes/sdk.install.test.d.ts +0 -2
- package/dist/routes/sdk.install.test.d.ts.map +0 -1
- package/dist/routes/sdk.install.test.js +0 -99
- package/dist/routes/sdk.install.test.js.map +0 -1
- package/dist/routes/sdk.resolve-click.test.d.ts +0 -2
- package/dist/routes/sdk.resolve-click.test.d.ts.map +0 -1
- package/dist/routes/sdk.resolve-click.test.js +0 -79
- package/dist/routes/sdk.resolve-click.test.js.map +0 -1
- package/dist/routes/sdk.resolve-scope.test.d.ts +0 -2
- package/dist/routes/sdk.resolve-scope.test.d.ts.map +0 -1
- package/dist/routes/sdk.resolve-scope.test.js +0 -105
- 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
|