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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Tests
on:
pull_request:
branches:
- '*'
- "*"
push:
branches:
- main
Expand All @@ -13,11 +13,11 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['20', '22']
node: ["20", "22"]
name: Node ${{ matrix.node }}
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- run: npm install
Expand Down
45 changes: 1 addition & 44 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Failing to initialize the class before calling any other method will throw a `No

Learn how to leverage the library to track events and collect forms.

- [Understanding Sessions](/docs/sessions.md)
- [Tracking Events](/docs/tracking.md)
- [Forms](/docs/forms.md)
- [Webchat](/docs/webchat.md)
Expand Down Expand Up @@ -105,50 +106,6 @@ Hellotext.removeEventListener(eventName, callback)
- `form:completed` This event is fired when a form has been completed. A form is completed when the user fills all required inputs and verifies their OTP(One-Time Password). The callback will receive the form object that was completed, alongside the data the user filled in the form.
- View Webchat events [here](/docs/webchat.md#events)

## Understanding Sessions

The library looks for a session identifier present on the `hello_session` query parameter. If the session is not present as a cookie neither it will create a new random session identifier, you can disable this default behaviour via the configuration, see [Configuration Options](#configuration-options) for more information.
The session is automatically sent to Hellotext any time the `Hellotext.track` method is called.

Short links redirections attaches a session identifier to the destination url as `hello_session` query parameter. This will identify all the events back to the customer who opened the link.

### Get session

It is possible to obtain the current session by simply calling `Hellotext.session`. When the session is present in the cookies,
the value stored in the cookies is returned. Otherwise, a new session is generated via [crypto.randomUUID()](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID).
The session is kept in the client and not sent to Hellotext's servers until an event is tracked.

An event is tracked in the following cases

- Explicitly calling `Hellotext.track` method.
- When a form is submitted and the form data is sent to Hellotext.

```javascript
Hellotext.session
// Returns da834c54-97fa-44ef-bafd-2bd4fec60636
```

If the session has not been set yet, the result returned will be `undefined`.
You can check whether the session has been set or not by calling `Hellotext.isInitialized`.

```javascript
if (Hellotext.isInitialized) {
console.log('session is present')
} else {
console.log('session has not been set')
}
```

Moreover, you can hook in and listen for the session being set, such that when it's set, you're notified about the change, like so

```javascript
Hellotext.on('session-set', session => {
console.log('session is: ', session)
})
```

You may want to store the session on your backend when customers are unidentified so you can later [attach it to a profile](https://www.hellotext.com/api#attach_session) when it becomes known.

### Configuration

When initializing the library, you may pass an optional configuration object as the second argument.
Expand Down
142 changes: 142 additions & 0 deletions __tests__/hellotext_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,146 @@ describe("when the class is initialized successfully", () => {
expect(requestBody).toHaveProperty('page.path', '/social-page')
});
});

describe("when identifying users", () => {
const business_id = "xy76ks"

beforeAll(() => {
const windowMock = {
location: { search: "?hello_session=test_session" },
}

jest.spyOn(global, 'window', 'get').mockImplementation(() => windowMock)
Hellotext.initialize(business_id)
})

afterEach(() => {
jest.clearAllMocks()
// 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"
})

it("sends correct request body with user and options", async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({received: "success"}),
status: 200,
ok: true
})

await Hellotext.identify("user_123", {
email: "user@example.com",
phone: "+1234567890",
name: "John Doe",
source: "shopify"
})

// Check that fetch was called
expect(global.fetch).toHaveBeenCalled()

// Get the fetch call arguments
const fetchCall = global.fetch.mock.calls[0]
const requestOptions = fetchCall[1]
const requestBody = JSON.parse(requestOptions.body)

// Verify the request body includes all expected fields
expect(requestBody).toHaveProperty('user_id', 'user_123')
expect(requestBody).toHaveProperty('email', 'user@example.com')
expect(requestBody).toHaveProperty('phone', '+1234567890')
expect(requestBody).toHaveProperty('name', 'John Doe')
expect(requestBody).toHaveProperty('source', 'shopify')
expect(requestBody).toHaveProperty('session', 'test_session')
})

it("sets cookies when identification succeeds", async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({received: "success"}),
status: 200,
ok: true
})

const response = await Hellotext.identify("user_456", {
email: "user@example.com",
source: "woocommerce"
})

expect(response.succeeded).toEqual(true)
expect(getCookieValue("hello_user_id")).toEqual("user_456")
expect(getCookieValue("hello_user_source")).toEqual("woocommerce")
})

it("does not set cookies when identification fails", async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({error: "invalid data"}),
status: 422,
ok: false
})

const response = await Hellotext.identify("user_789", {
email: "invalid-email",
source: "magento"
})

expect(response.failed).toEqual(true)
expect(getCookieValue("hello_user_id")).toBeUndefined()
expect(getCookieValue("hello_user_source")).toBeUndefined()
})

it("works with minimal options (only user id)", async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({received: "success"}),
status: 200,
ok: true
})

await Hellotext.identify("user_minimal")

// Get the fetch call arguments
const fetchCall = global.fetch.mock.calls[0]
const requestOptions = fetchCall[1]
const requestBody = JSON.parse(requestOptions.body)

// Verify the request body includes only user and session
expect(requestBody).toHaveProperty('user_id', 'user_minimal')
expect(requestBody).toHaveProperty('session', 'test_session')
expect(requestBody).not.toHaveProperty('email')
expect(requestBody).not.toHaveProperty('phone')
expect(requestBody).not.toHaveProperty('name')
})

it("handles undefined source in options", async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({received: "success"}),
status: 200,
ok: true
})

await Hellotext.identify("user_no_source", {
email: "user@example.com"
})

expect(getCookieValue("hello_user_id")).toEqual("user_no_source")
expect(getCookieValue("hello_user_source")).toBeUndefined()
})

it("sends session from Hellotext context in request", async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({received: "success"}),
status: 200,
ok: true
})

await Hellotext.identify("user_session_test", {
email: "test@example.com"
})

// Get the fetch call arguments
const fetchCall = global.fetch.mock.calls[0]
const requestOptions = fetchCall[1]
const requestBody = JSON.parse(requestOptions.body)

// Verify the session is included from Hellotext.session
expect(requestBody).toHaveProperty('session', 'test_session')
})
})
});
102 changes: 102 additions & 0 deletions __tests__/models/user_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* @jest-environment jsdom
*/

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'
jest.clearAllMocks()
})

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

it('returns the user id from cookie', () => {
Cookies.set('hello_user_id', 'user_123')
expect(User.id).toEqual('user_123')
})
})

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

it('returns the source from cookie', () => {
Cookies.set('hello_user_source', 'shopify')
expect(User.source).toEqual('shopify')
})
})

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

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

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

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

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

expect(User.id).toEqual('user_new')
expect(User.source).toEqual('woocommerce')
})
})

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

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

it('does not throw when no cookies exist', () => {
expect(() => User.forget()).not.toThrow()
})
})

describe('identificationData', () => {
it('returns empty object when no user identified', () => {
expect(User.identificationData).toEqual({})
})

it('returns user_id and source when both set', () => {
User.remember('user_111', 'shopify')

expect(User.identificationData).toEqual({
id: 'user_111',
source: 'shopify'
})
})

it('returns empty object when only source set', () => {
Cookies.set('hello_user_source', 'shopify')
expect(User.identificationData).toEqual({})
})

it('includes undefined source when not provided', () => {
User.remember('user_222')

expect(User.identificationData).toEqual({
id: 'user_222',
source: undefined
})
})
})
})
2 changes: 1 addition & 1 deletion dist/hellotext.js

Large diffs are not rendered by default.

Loading