Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 107 additions & 9 deletions __tests__/hellotext_test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Hellotext from "../src/hellotext";
import { Business } from "../src/models";
import { Business, Session } from "../src/models";

const getCookieValue = name => document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop()

Expand Down Expand Up @@ -264,6 +264,7 @@ describe("when the class is initialized successfully", () => {
// Clear identification cookies
document.cookie = "hello_user_id=;expires=Thu, 01 Jan 1970 00:00:00 GMT"
document.cookie = "hello_user_source=;expires=Thu, 01 Jan 1970 00:00:00 GMT"
document.cookie = "hello_user_identification_hash=;expires=Thu, 01 Jan 1970 00:00:00 GMT"
})

it("sends correct request body with user and options", async () => {
Expand Down Expand Up @@ -312,6 +313,7 @@ describe("when the class is initialized successfully", () => {
expect(response.succeeded).toEqual(true)
expect(getCookieValue("hello_user_id")).toEqual("user_456")
expect(getCookieValue("hello_user_source")).toEqual("woocommerce")
expect(getCookieValue("hello_user_identification_hash")).toMatch(/^v1:/)
})

it("does not set cookies when identification fails", async () => {
Expand All @@ -329,6 +331,7 @@ describe("when the class is initialized successfully", () => {
expect(response.failed).toEqual(true)
expect(getCookieValue("hello_user_id")).toBeUndefined()
expect(getCookieValue("hello_user_source")).toBeUndefined()
expect(getCookieValue("hello_user_identification_hash")).toBeUndefined()
})

it("works with minimal options (only user id)", async () => {
Expand Down Expand Up @@ -388,30 +391,125 @@ describe("when the class is initialized successfully", () => {
expect(requestBody).toHaveProperty('session', 'test_session')
})

it("skips API call when user is already identified with same ID", async () => {
it("skips API call when identify payload is unchanged", async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({received: "success"}),
status: 200,
ok: true
})

// First identify the user
await Hellotext.identify("user_existing", {
email: "existing@example.com",
source: "shopify"
shopify: {
customer: {
email: "existing@example.com",
phone: "+1234567890",
},
domain: "example.myshopify.com",
},
tags: ["vip", "repeat"],
source: "shopify",
})

expect(global.fetch).toHaveBeenCalledTimes(1)

// Try to identify with the same ID again
const response = await Hellotext.identify("user_existing", {
email: "different@example.com",
source: "woocommerce"
tags: ["vip", "repeat"],
shopify: {
domain: "example.myshopify.com",
customer: {
phone: "+1234567890",
email: "existing@example.com",
},
},
source: "shopify",
})

// Should not make another API call
expect(global.fetch).toHaveBeenCalledTimes(1)
expect(response.succeeded).toEqual(true)
})

it("does not skip API call when identify options change for the same user", async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({received: "success"}),
status: 200,
ok: true
})

await Hellotext.identify("user_existing", {
email: "existing@example.com",
source: "shopify"
})

await Hellotext.identify("user_existing", {
email: "existing@example.com",
phone: "+1234567890",
source: "shopify"
})

expect(global.fetch).toHaveBeenCalledTimes(2)
})

it("does not skip API call when array order changes", async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({received: "success"}),
status: 200,
ok: true
})

await Hellotext.identify("user_existing", {
tags: ["vip", "repeat"],
source: "shopify"
})

await Hellotext.identify("user_existing", {
tags: ["repeat", "vip"],
source: "shopify"
})

expect(global.fetch).toHaveBeenCalledTimes(2)
})

it("does not skip API call when the session changes", async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({received: "success"}),
status: 200,
ok: true
})

await Hellotext.identify("user_existing", {
email: "existing@example.com",
source: "shopify"
})

Session.session = "new_session"
global.fetch.mockClear()

await Hellotext.identify("user_existing", {
email: "existing@example.com",
source: "shopify"
})

expect(global.fetch).toHaveBeenCalledTimes(1)
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toHaveProperty('session', 'new_session')
})

it("sends once for legacy cookies without a fingerprint and seeds it afterward", async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({received: "success"}),
status: 200,
ok: true
})

document.cookie = "hello_user_id=user_existing"
document.cookie = "hello_user_source=shopify"

await Hellotext.identify("user_existing", {
email: "existing@example.com",
source: "shopify"
})

expect(global.fetch).toHaveBeenCalledTimes(1)
expect(getCookieValue("hello_user_identification_hash")).toMatch(/^v1:/)
})
})
});
96 changes: 96 additions & 0 deletions __tests__/models/fingerprint_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* @jest-environment jsdom
*/

import { Fingerprint } from '../../src/models'

describe('Fingerprint', () => {
it('matches when nested object keys are reordered', async () => {
const storedFingerprint = await Fingerprint.generate('session_123', 'user_123', {
tags: ['vip', 'repeat'],
shopify: {
customer: {
email: 'customer@example.com',
phone: '+1234567890',
},
domain: 'example.myshopify.com',
},
})

const fingerprint = await Fingerprint.generate('session_123', 'user_123', {
shopify: {
domain: 'example.myshopify.com',
customer: {
phone: '+1234567890',
email: 'customer@example.com',
},
},
tags: ['vip', 'repeat'],
})

expect(
Fingerprint.matches(storedFingerprint, fingerprint),
).toEqual(true)
})

it('does not match when array order changes', async () => {
const storedFingerprint = await Fingerprint.generate('session_123', 'user_123', {
tags: ['vip', 'repeat'],
})

const fingerprint = await Fingerprint.generate('session_123', 'user_123', {
tags: ['repeat', 'vip'],
})

expect(
Fingerprint.matches(storedFingerprint, fingerprint),
).toEqual(false)
})

it('treats blank strings as omitted values', async () => {
const storedFingerprint = await Fingerprint.generate('session_123', 'user_123', {
email: 'customer@example.com',
phone: '',
shopify: {
domain: 'example.myshopify.com',
},
})

const fingerprint = await Fingerprint.generate('session_123', 'user_123', {
shopify: {
domain: 'example.myshopify.com',
},
email: 'customer@example.com',
})

expect(
Fingerprint.matches(storedFingerprint, fingerprint),
).toEqual(true)
})

it('does not match when the session changes', async () => {
const storedFingerprint = await Fingerprint.generate('session_123', 'user_123', {
email: 'customer@example.com',
})

const fingerprint = await Fingerprint.generate('session_456', 'user_123', {
email: 'customer@example.com',
})

expect(
Fingerprint.matches(storedFingerprint, fingerprint),
).toEqual(false)
})

it('does not match when the stored fingerprint is missing', async () => {
const fingerprint = await Fingerprint.generate('session_123', 'user_123', {
shopify: {
domain: 'example.myshopify.com',
},
})

expect(
Fingerprint.matches(undefined, fingerprint),
).toEqual(false)
})
})
26 changes: 21 additions & 5 deletions __tests__/models/user_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Cookies, User } from '../../src/models'
beforeEach(() => {
document.cookie = 'hello_user_id=;expires=Thu, 01 Jan 1970 00:00:00 GMT'
document.cookie = 'hello_user_source=;expires=Thu, 01 Jan 1970 00:00:00 GMT'
document.cookie = 'hello_user_identification_hash=;expires=Thu, 01 Jan 1970 00:00:00 GMT'
jest.clearAllMocks()
})

Expand All @@ -33,37 +34,52 @@ describe('User', () => {
})
})

describe('fingerprint', () => {
it('returns undefined when not set', () => {
expect(User.fingerprint).toBeUndefined()
})

it('returns the fingerprint from cookie', () => {
Cookies.set('hello_user_identification_hash', 'v1:fingerprint')
expect(User.fingerprint).toEqual('v1:fingerprint')
})
})

describe('remember', () => {
it('sets both user_id and source when both provided', () => {
User.remember('user_123', 'shopify')
it('sets user_id, source, and fingerprint when all are provided', () => {
User.remember('user_123', 'shopify', 'v1:fingerprint')

expect(User.id).toEqual('user_123')
expect(User.source).toEqual('shopify')
expect(User.fingerprint).toEqual('v1:fingerprint')
})

it('sets only user_id when source is falsy', () => {
User.remember('user_456', undefined)

expect(User.id).toEqual('user_456')
expect(User.source).toBeUndefined()
expect(User.fingerprint).toBeUndefined()
})

it('updates existing cookies', () => {
User.remember('user_old', 'shopify')
User.remember('user_new', 'woocommerce')
User.remember('user_old', 'shopify', 'v1:old')
User.remember('user_new', 'woocommerce', 'v1:new')

expect(User.id).toEqual('user_new')
expect(User.source).toEqual('woocommerce')
expect(User.fingerprint).toEqual('v1:new')
})
})

describe('forget', () => {
it('removes user cookies', () => {
User.remember('user_789', 'shopify')
User.remember('user_789', 'shopify', 'v1:fingerprint')
User.forget()

expect(User.id).toBeUndefined()
expect(User.source).toBeUndefined()
expect(User.fingerprint).toBeUndefined()
})

it('does not throw when no cookies exist', () => {
Expand Down
2 changes: 1 addition & 1 deletion dist/hellotext.js

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ declare class Hellotext {
export declare class User {
static get id(): string | undefined
static get source(): string | undefined
static remember(id: string, source?: string): void
static get fingerprint(): string | undefined
static remember(id: string, source?: string, fingerprint?: string): void
static forget(): void
static get identificationData(): IdentificationData | Record<string, never>
}
Expand Down
15 changes: 9 additions & 6 deletions lib/hellotext.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,27 +85,30 @@ let Hellotext = /*#__PURE__*/function () {
* @property { String } [name] - the name of the user
* @property { String } [source] - the platform specific identifier where this pixel is running on.
*
* Identifies a user and attaches the hello_session to the user ID
* Identifies a user and attaches the hello_session to the user ID.
* Repeated calls are skipped only when the last successful identify payload
* for the current session remains unchanged.
* @param { String } externalId - the user ID
* @param { IdentificationOptions } options - the options for the identification
* @returns {Promise<Response>}
*/
}, {
key: "identify",
value: async function identify(externalId, options = {}) {
if (_models.User.id === externalId) {
const fingerprint = await _models.Fingerprint.generate(this.session, externalId, options);
if (_models.Fingerprint.matches(_models.User.fingerprint, fingerprint)) {
return new _api.Response(true, {
json: async () => {
already_identified: true;
}
json: async () => ({
already_identified: true
})
});
}
const response = await _api.default.identifications.create({
user_id: externalId,
...options
});
if (response.succeeded) {
_models.User.remember(externalId, options.source);
_models.User.remember(externalId, options.source, fingerprint);
}
return response;
}
Expand Down
Loading