diff --git a/package.json b/package.json index 8a657e84..4c38824b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@iterable/web-sdk", "description": "Iterable SDK for JavaScript and Node.", - "version": "1.1.2", + "version": "1.2.0-beta", "homepage": "https://iterable.com/", "repository": { "type": "git", @@ -74,7 +74,7 @@ "@types/jest": "^27.0.2", "@types/node": "^12.7.1", "@types/throttle-debounce": "^2.1.0", - "@types/uuid": "^9.0.2", + "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^5.38.1", "@typescript-eslint/parser": "^5.38.1", "@webpack-cli/serve": "^1.6.0", diff --git a/react-example/.eslintrc b/react-example/.eslintrc index 1583a522..1f749a6a 100644 --- a/react-example/.eslintrc +++ b/react-example/.eslintrc @@ -1,13 +1,8 @@ { - "extends": [ - "../.eslintrc", - "plugin:react/recommended" - ], + "extends": ["../.eslintrc", "plugin:react/recommended"], "rules": { "@typescript-eslint/no-empty-interface": "off", - "react/react-in-jsx-scope": "off", + "react/react-in-jsx-scope": "off" }, - "ignorePatterns": [ - "node_modules/" - ] -} \ No newline at end of file + "ignorePatterns": ["node_modules/"] +} diff --git a/react-example/src/components/EventsForm.tsx b/react-example/src/components/EventsForm.tsx index 7bf6650f..4a113612 100644 --- a/react-example/src/components/EventsForm.tsx +++ b/react-example/src/components/EventsForm.tsx @@ -28,10 +28,9 @@ export const EventsForm: FC = ({ ); const [trackEvent, setTrackEvent] = useState(''); - const [isTrackingEvent, setTrackingEvent] = useState(false); - const handleTrack = (e: FormEvent) => { + const handleTrack = async (e: FormEvent) => { e.preventDefault(); setTrackingEvent(true); @@ -50,7 +49,6 @@ export const EventsForm: FC = ({ }) .catch((e: any) => { setTrackResponse(JSON.stringify(e.response.data)); - setTrackingEvent(false); }); }; diff --git a/react-example/src/components/LoginFormWithoutJWT.tsx b/react-example/src/components/LoginFormWithoutJWT.tsx new file mode 100644 index 00000000..f1e6c206 --- /dev/null +++ b/react-example/src/components/LoginFormWithoutJWT.tsx @@ -0,0 +1,154 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { ChangeEvent, FC, FormEvent, useState } from 'react'; +import styled from 'styled-components'; + +import { IdentityResolution } from '@iterable/web-sdk'; +import { TextField as _TextField } from './TextField'; +import { Button as _Button } from './Button'; + +import { useUser } from '../context/Users'; + +const TextField = styled(_TextField)``; + +const Button = styled(_Button)` + margin-left: 0.4em; + max-width: 425px; +`; + +const Form = styled.form` + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-end; + height: 100%; + + ${TextField} { + align-self: stretch; + margin-top: 5px; + } +`; + +const StyledDiv = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; +`; + +const Error = styled.div` + color: red; +`; + +interface Props { + setEmail: (email: string) => Promise; + setUserId: ( + userId: string, + identityResolution?: IdentityResolution + ) => Promise; + logout: () => void; +} + +export const LoginFormWithoutJWT: FC = ({ + setEmail, + setUserId, + logout +}) => { + const [useEmail, setUseEmail] = useState(true); + const [user, updateUser] = useState(process.env.LOGIN_EMAIL || ''); + + const [error, setError] = useState(''); + + const [isEditingUser, setEditingUser] = useState(false); + + const { loggedInUser, setLoggedInUser } = useUser(); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + + const setUser = useEmail ? setEmail : setUserId; + + setUser(user) + .then(() => { + setEditingUser(false); + setLoggedInUser({ type: 'user_update', data: user }); + }) + .catch(() => setError('Something went wrong!')); + }; + + const handleLogout = () => { + logout(); + setLoggedInUser({ type: 'user_update', data: '' }); + }; + + const handleEditUser = () => { + updateUser(loggedInUser); + setEditingUser(true); + }; + + const handleCancelEditUser = () => { + updateUser(''); + setEditingUser(false); + }; + + const handleRadioChange = (e: ChangeEvent) => { + setUseEmail(e.target.value === 'email'); + }; + + const first5 = loggedInUser.substring(0, 5); + const last9 = loggedInUser.substring(loggedInUser.length - 9); + + return ( + <> + {loggedInUser && !isEditingUser ? ( + <> + + + + ) : ( + +
+
+ + +
+
+ + +
+
+
+ updateUser(e.target.value)} + value={user} + placeholder="e.g. hello@gmail.com" + required + data-qa-login-input + /> + + {isEditingUser && ( + + )} + + {error && {error}} +
+ )} + + ); +}; + +export default LoginFormWithoutJWT; diff --git a/react-example/src/index.tsx b/react-example/src/index.tsx index 36d698eb..831ec707 100644 --- a/react-example/src/index.tsx +++ b/react-example/src/index.tsx @@ -14,6 +14,7 @@ import { EmbeddedMessage } from './views/Embedded'; import { Link } from './components/Link'; import { LoginForm } from './components/LoginForm'; import { EmbeddedMsgs } from './views/EmbeddedMsgs'; +import AUTTesting from './views/AUTTesting'; import { UserProvider } from './context/Users'; import { EmbeddedMsgsImpressionTracker } from './views/EmbeddedMsgsImpressionTracker'; @@ -45,7 +46,8 @@ const HomeLink = styled(Link)` authToken: process.env.API_KEY || '', configOptions: { isEuIterableService: false, - dangerouslyAllowJsPopups: true + dangerouslyAllowJsPopups: true, + enableAnonActivation: true }, generateJWT: ({ email, userID }) => axios @@ -65,8 +67,15 @@ const HomeLink = styled(Link)` ) .then((response: any) => response.data?.token) }; - const { setEmail, setUserID, logout, refreshJwtToken } = - initializeWithConfig(initializeParams); + const { + setEmail, + setUserID, + logout, + refreshJwtToken, + setVisitorUsageTracked + } = initializeWithConfig(initializeParams); + + const handleConsent = (consent?: boolean) => setVisitorUsageTracked(consent); const container = document.getElementById('root'); const root = createRoot(container); @@ -98,6 +107,10 @@ const HomeLink = styled(Link)` path="/embedded-msgs-impression-tracker" element={} /> + } + /> diff --git a/react-example/src/indexWithoutJWT.tsx b/react-example/src/indexWithoutJWT.tsx new file mode 100644 index 00000000..427ce25d --- /dev/null +++ b/react-example/src/indexWithoutJWT.tsx @@ -0,0 +1,100 @@ +import { initializeWithConfig, WithoutJWTParams } from '@iterable/web-sdk'; +import ReactDOM from 'react-dom'; +import './styles/index.css'; + +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import styled from 'styled-components'; +import { Home } from './views/Home'; +import { Commerce } from './views/Commerce'; +import { Events } from './views/Events'; +import { Users } from './views/Users'; +import { InApp } from './views/InApp'; +import LoginFormWithoutJWT from './components/LoginFormWithoutJWT'; +import AUTTesting from './views/AUTTesting'; +import { EmbeddedMsgs } from './views/EmbeddedMsgs'; +import { EmbeddedMessage } from './views/Embedded'; +import { EmbeddedMsgsImpressionTracker } from './views/EmbeddedMsgsImpressionTracker'; +import { Link } from './components/Link'; +import { UserProvider } from './context/Users'; + +const Wrapper = styled.div` + display: flex; + flex-flow: column; +`; + +const RouteWrapper = styled.div` + width: 90%; + margin: 0 auto; +`; + +const HeaderWrapper = styled.div` + display: flex; + flex-flow: row; + align-items: center; + justify-content: space-between; + margin: 1em; +`; + +const HomeLink = styled(Link)` + width: 100px; +`; + +((): void => { + // Here we are testing it using NON-JWT based project. + const initializeParams: WithoutJWTParams = { + authToken: process.env.API_KEY || '', + configOptions: { + isEuIterableService: false, + dangerouslyAllowJsPopups: true, + enableAnonActivation: true, + onAnonUserCreated: (userId: string) => { + console.log('onAnonUserCreated', userId); + } + } + }; + + const { setUserID, logout, setEmail, setVisitorUsageTracked } = + initializeWithConfig(initializeParams); + + const handleConsent = (consent?: boolean) => setVisitorUsageTracked(consent); + + // eslint-disable-next-line react/no-deprecated + ReactDOM.render( + + + + + + Home + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } + /> + + + + + , + document.getElementById('root') + ); +})(); diff --git a/react-example/src/styles/index.css b/react-example/src/styles/index.css index 28642755..e6033d05 100644 --- a/react-example/src/styles/index.css +++ b/react-example/src/styles/index.css @@ -1,4 +1,5 @@ -html, body { +html, +body { margin: 0; padding: 0; } @@ -30,7 +31,7 @@ html, body { } #change-email-form input { - margin-top: .5em; + margin-top: 0.5em; flex-grow: 1; padding: 1em; } @@ -42,17 +43,17 @@ html, body { flex-flow: column; justify-content: center; } - + .input-wrapper { margin-right: 0; transform: translateY(0); } - + #change-email-form button { width: 100%; margin-top: 1em; } - + #change-email-form input { height: 50px; } @@ -62,4 +63,34 @@ footer { display: flex; justify-content: flex-end; align-items: flex-end; -} \ No newline at end of file +} + +#cookie-consent-container { + display: flex; + justify-content: center; + flex-direction: column; + + position: fixed; + bottom: 0; + right: 0; + + box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.2); + padding: 1em; + background: #fff; + margin: 1em; + max-width: 400px; + + h3 { + margin-top: 0; + margin-bottom: 0.5em; + } + + p { + margin-top: 0; + } + + div { + display: flex; + gap: 0.5em; + } +} diff --git a/react-example/src/views/AUTTesting.tsx b/react-example/src/views/AUTTesting.tsx new file mode 100644 index 00000000..e0a0ab2c --- /dev/null +++ b/react-example/src/views/AUTTesting.tsx @@ -0,0 +1,304 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { FC, FormEvent, useState } from 'react'; +import { + updateCart, + trackPurchase, + UpdateCartRequestParams, + TrackPurchaseRequestParams, + updateUser, + UpdateUserParams, + track, + InAppTrackRequestParams +} from '@iterable/web-sdk'; +import { TextField } from '../components/TextField'; +import { + Button, + EndpointWrapper, + Form, + Heading, + Response +} from './Components.styled'; + +interface Props { + setConsent?: (accept: boolean) => void; +} + +export const AUTTesting: FC = ({ setConsent }) => { + const [updateCartResponse, setUpdateCartResponse] = useState( + 'Endpoint JSON goes here' + ); + const [trackPurchaseResponse, setTrackPurchaseResponse] = useState( + 'Endpoint JSON goes here' + ); + + const [cartItem, setCartItem] = useState( + '{"items":[{"name":"piano","id":"fdsafds","price":100,"quantity":2}, {"name":"piano2","id":"fdsafds2","price":100,"quantity":5}]}' + ); + + const [purchaseItem, setPurchaseItem] = useState( + '{"items":[{"name":"Black Coffee","id":"fdsafds","price":100,"quantity":2}], "total": 100}' + ); + + const [isUpdatingCart, setUpdatingCart] = useState(false); + const [isTrackingPurchase, setTrackingPurchase] = useState(false); + const [userDataField, setUserDataField] = useState( + ' { "dataFields": {"email": "user@example.com","furniture": [{"furnitureType": "Sofa","furnitureColor": "White","lengthInches": 40,"widthInches": 60},{"furnitureType": "Sofa","furnitureColor": "Gray","lengthInches": 20,"widthInches": 30}] }}' + ); + const [isUpdatingUser, setUpdatingUser] = useState(false); + const [updateUserResponse, setUpdateUserResponse] = useState( + 'Endpoint JSON goes here' + ); + + const [trackResponse, setTrackResponse] = useState( + 'Endpoint JSON goes here' + ); + + const eventInput = + '{"eventName":"button-clicked", "dataFields": {"browserVisit.website.domain":"https://mybrand.com/socks"}}'; + const [trackEvent, setTrackEvent] = useState(eventInput); + const [isTrackingEvent, setTrackingEvent] = useState(false); + + const handleParseJson = (isUpdateCartCalled: boolean) => { + try { + // Parse JSON and assert its type + if (isUpdateCartCalled) { + const parsedObject = JSON.parse(cartItem) as UpdateCartRequestParams; + return parsedObject; + } + const parsedObject = JSON.parse( + purchaseItem + ) as TrackPurchaseRequestParams; + return parsedObject; + } catch (error) { + if (isUpdateCartCalled) { + setUpdateCartResponse(JSON.stringify(error.message)); + } else setTrackPurchaseResponse(JSON.stringify(error.message)); + return error; + } + }; + + const handleParseUserJson = () => { + try { + // Parse JSON and assert its type + return JSON.parse(userDataField) as UpdateUserParams; + } catch (error) { + setUpdateUserResponse(JSON.stringify(error.message)); + return error; + } + }; + + const handleUpdateCart = (e: FormEvent) => { + e.preventDefault(); + const jsonObj: UpdateCartRequestParams = handleParseJson(true); + if (jsonObj) { + setUpdatingCart(true); + try { + updateCart(jsonObj) + .then((response: any) => { + setUpdateCartResponse(JSON.stringify(response.data)); + setUpdatingCart(false); + }) + .catch((e: any) => { + setUpdateCartResponse(JSON.stringify(e)); + setUpdatingCart(false); + }); + } catch (error) { + setUpdateCartResponse(JSON.stringify(error.message)); + setUpdatingCart(false); + } + } + }; + + const handleTrackPurchase = (e: FormEvent) => { + e.preventDefault(); + const jsonObj: TrackPurchaseRequestParams = handleParseJson(false); + if (jsonObj) { + setTrackingPurchase(true); + try { + trackPurchase(jsonObj) + .then((response: any) => { + setTrackingPurchase(false); + setTrackPurchaseResponse(JSON.stringify(response.data)); + }) + .catch((e: any) => { + setTrackingPurchase(false); + setTrackPurchaseResponse(JSON.stringify(e)); + }); + } catch (error) { + setTrackingPurchase(false); + setTrackPurchaseResponse(JSON.stringify(error.message)); + } + } + }; + + const handleUpdateUser = (e: FormEvent) => { + e.preventDefault(); + const jsonObj = handleParseUserJson(); + if (jsonObj) { + setUpdatingUser(true); + try { + updateUser(jsonObj) + .then((response: any) => { + setUpdateUserResponse(JSON.stringify(response.data)); + setUpdatingUser(false); + }) + .catch((e: any) => { + setUpdateUserResponse(JSON.stringify(e)); + setUpdatingUser(false); + }); + } catch (error) { + setUpdateUserResponse(JSON.stringify(error.message)); + setUpdatingUser(false); + } + } + }; + + const handleParseTrackJson = () => { + try { + // Parse JSON and assert its type + const parsedObject = JSON.parse(trackEvent) as InAppTrackRequestParams; + return parsedObject; + } catch (error) { + setTrackResponse(JSON.stringify(error.message)); + return error; + } + }; + + const handleTrack = async (e: FormEvent) => { + e.preventDefault(); + setTrackingEvent(true); + + const jsonObj = handleParseTrackJson(); + if (jsonObj) { + const conditionalParams = jsonObj; + + try { + track({ + ...conditionalParams, + deviceInfo: { + appPackageName: 'my-website' + } + }) + .then((response: any) => { + setTrackResponse(JSON.stringify(response.data)); + setTrackingEvent(false); + }) + .catch((e: any) => { + if (e && e.response && e.response.data) { + setTrackResponse(JSON.stringify(e.response.data)); + } else { + setTrackResponse(JSON.stringify(e)); + } + setTrackingEvent(false); + }); + } catch (error) { + setTrackResponse(JSON.stringify(error.message)); + setTrackingEvent(false); + } + } + }; + + const formAttr = { 'data-qa-track-submit': true }; + const inputAttr = { 'data-qa-track-input': true }; + const responseAttr = { 'data-qa-track-response': true }; + + const acceptCookie = () => setConsent(true); + + const declineCookie = () => setConsent(false); + + const renderCookieConsent = setConsent && ( + + ); + + return ( + <> +

Commerce Endpoints

+ POST /updateCart + +
+ + setCartItem(e.target.value)} + id="item-1" + placeholder='e.g. {"items":[{"name":"piano","id":"fdsafds"}]}' + data-qa-cart-input + /> + + + {updateCartResponse} +
+ POST /trackPurchase + +
+ + setPurchaseItem(e.target.value)} + id="item-2" + placeholder='e.g. {"items":[{"id":"fdsafds","price":100}]}' + data-qa-purchase-input + /> + + + {trackPurchaseResponse} +
+

User Endpoint

+ POST /users/update + +
+ + setUserDataField(e.target.value)} + id="item-1" + placeholder="e.g. phone_number" + data-qa-update-user-input + required + /> + + + {updateUserResponse} +
+

Events Endpoint

+ POST /track + +
+ + setTrackEvent(e.target.value)} + id="item-1" + placeholder='e.g. {"eventName":"button-clicked"}' + {...inputAttr} + /> + + + {trackResponse} +
+ {renderCookieConsent} + + ); +}; + +export default AUTTesting; diff --git a/react-example/src/views/Commerce.tsx b/react-example/src/views/Commerce.tsx index f64c281c..74500397 100644 --- a/react-example/src/views/Commerce.tsx +++ b/react-example/src/views/Commerce.tsx @@ -28,34 +28,44 @@ export const Commerce: FC = () => { const handleUpdateCart = (e: FormEvent) => { e.preventDefault(); setUpdatingCart(true); - updateCart({ - items: [{ name: cartItem, id: 'fdsafds', price: 100, quantity: 2 }] - }) - .then((response: any) => { - setUpdateCartResponse(JSON.stringify(response.data)); - setUpdatingCart(false); + try { + updateCart({ + items: [{ name: cartItem, id: 'fdsafds', price: 100, quantity: 2 }] }) - .catch((e: any) => { - setUpdateCartResponse(JSON.stringify(e.response.data)); - setUpdatingCart(false); - }); + .then((response: any) => { + setUpdateCartResponse(JSON.stringify(response.data)); + setUpdatingCart(false); + }) + .catch((e: any) => { + setUpdateCartResponse(JSON.stringify(e.response.data)); + setUpdatingCart(false); + }); + } catch (error) { + setUpdateCartResponse(JSON.stringify(error.message)); + setUpdatingCart(false); + } }; const handleTrackPurchase = (e: FormEvent) => { e.preventDefault(); setTrackingPurchase(true); - trackPurchase({ - items: [{ name: purchaseItem, id: 'fdsafds', price: 100, quantity: 2 }], - total: 200 - }) - .then((response: any) => { - setTrackingPurchase(false); - setTrackPurchaseResponse(JSON.stringify(response.data)); + try { + trackPurchase({ + items: [{ name: purchaseItem, id: 'fdsafds', price: 100, quantity: 2 }], + total: 200 }) - .catch((e: any) => { - setTrackingPurchase(false); - setTrackPurchaseResponse(JSON.stringify(e.response.data)); - }); + .then((response: any) => { + setTrackingPurchase(false); + setTrackPurchaseResponse(JSON.stringify(response.data)); + }) + .catch((e: any) => { + setTrackingPurchase(false); + setTrackPurchaseResponse(JSON.stringify(e.response.data)); + }); + } catch (error) { + setTrackingPurchase(false); + setTrackPurchaseResponse(JSON.stringify(error.message)); + } }; return ( diff --git a/react-example/src/views/Components.styled.ts b/react-example/src/views/Components.styled.ts index e35d7959..0f2b9e58 100644 --- a/react-example/src/views/Components.styled.ts +++ b/react-example/src/views/Components.styled.ts @@ -40,3 +40,5 @@ export const Heading = styled.h2` export const StyledButton = styled(Button)` margin-top: 1em; `; + +export { Button }; diff --git a/react-example/src/views/Home.tsx b/react-example/src/views/Home.tsx index 749a0584..3c4d317c 100644 --- a/react-example/src/views/Home.tsx +++ b/react-example/src/views/Home.tsx @@ -41,6 +41,9 @@ export const Home: FC = () => ( Embedded msgs impressions tracker + + AUT Testing + ); diff --git a/react-example/src/views/Users.tsx b/react-example/src/views/Users.tsx index 4273d228..df990e36 100644 --- a/react-example/src/views/Users.tsx +++ b/react-example/src/views/Users.tsx @@ -40,17 +40,22 @@ export const Users: FC = () => { const handleUpdateUser = (e: FormEvent) => { e.preventDefault(); setUpdatingUser(true); - updateUser({ - dataFields: { [userDataField]: 'test-data' } - }) - .then((response: any) => { - setUpdateUserResponse(JSON.stringify(response.data)); - setUpdatingUser(false); + try { + updateUser({ + dataFields: { [userDataField]: 'test-data' } }) - .catch((e: any) => { - setUpdateUserResponse(JSON.stringify(e.response.data)); - setUpdatingUser(false); - }); + .then((response: any) => { + setUpdateUserResponse(JSON.stringify(response.data)); + setUpdatingUser(false); + }) + .catch((e: any) => { + setUpdateUserResponse(JSON.stringify(e.response.data)); + setUpdatingUser(false); + }); + } catch (error) { + setUpdateUserResponse(JSON.stringify(error.message)); + setUpdatingUser(false); + } }; const handleUpdateUserEmail = (e: FormEvent) => { diff --git a/src/anonymousUserTracking/anonymousUserEventManager.ts b/src/anonymousUserTracking/anonymousUserEventManager.ts new file mode 100644 index 00000000..497f9684 --- /dev/null +++ b/src/anonymousUserTracking/anonymousUserEventManager.ts @@ -0,0 +1,441 @@ +/* eslint-disable class-methods-use-this */ +import { v4 as uuidv4 } from 'uuid'; +import { + UpdateCartRequestParams, + TrackPurchaseRequestParams +} from '../commerce/types'; + +import { + GET_CRITERIA_PATH, + KEY_EVENT_NAME, + KEY_CREATED_AT, + KEY_DATA_FIELDS, + KEY_CREATE_NEW_FIELDS, + SHARED_PREFS_EVENT_TYPE, + TRACK_EVENT, + SHARED_PREFS_EVENT_LIST_KEY, + KEY_ITEMS, + KEY_TOTAL, + TRACK_PURCHASE, + UPDATE_USER, + TRACK_UPDATE_CART, + SHARED_PREFS_CRITERIA, + SHARED_PREFS_ANON_SESSIONS, + ENDPOINT_TRACK_ANON_SESSION, + WEB_PLATFORM, + KEY_PREFER_USERID, + ENDPOINTS, + DEFAULT_EVENT_THRESHOLD_LIMIT, + SHARED_PREF_ANON_USAGE_TRACKED +} from '../constants'; +import { baseIterableRequest } from '../request'; +import { IterableResponse } from '../types'; +import CriteriaCompletionChecker from './criteriaCompletionChecker'; +import { TrackAnonSessionParams } from '../utils/types'; +import { UpdateUserParams } from '../users/types'; +import { trackSchema } from '../events/events.schema'; +import { + trackPurchaseSchema, + updateCartSchema +} from '../commerce/commerce.schema'; +import { updateUserSchema } from '../users/users.schema'; +import { InAppTrackRequestParams } from '../events'; +import config from '../utils/config'; + +type AnonUserFunction = (userId: string) => void; + +let anonUserIdSetter: AnonUserFunction | null = null; + +export function registerAnonUserIdSetter(setterFunction: AnonUserFunction) { + anonUserIdSetter = setterFunction; +} + +export function isAnonymousUsageTracked(): boolean { + const anonymousUsageTracked = localStorage.getItem( + SHARED_PREF_ANON_USAGE_TRACKED + ); + return anonymousUsageTracked === 'true'; +} + +export class AnonymousUserEventManager { + updateAnonSession() { + try { + const anonymousUsageTracked = isAnonymousUsageTracked(); + + if (!anonymousUsageTracked) return; + + const strAnonSessionInfo = localStorage.getItem( + SHARED_PREFS_ANON_SESSIONS + ); + let anonSessionInfo: { + itbl_anon_sessions?: { + number_of_sessions?: number; + first_session?: number; + last_session?: number; + }; + } = {}; + + if (strAnonSessionInfo) { + anonSessionInfo = JSON.parse(strAnonSessionInfo); + } + + // Update existing values or set them if they don't exist + anonSessionInfo.itbl_anon_sessions = + anonSessionInfo.itbl_anon_sessions || {}; + anonSessionInfo.itbl_anon_sessions.number_of_sessions = + (anonSessionInfo.itbl_anon_sessions.number_of_sessions || 0) + 1; + anonSessionInfo.itbl_anon_sessions.first_session = + anonSessionInfo.itbl_anon_sessions.first_session || + this.getCurrentTime(); + anonSessionInfo.itbl_anon_sessions.last_session = this.getCurrentTime(); + + // Update the structure to the desired format + const outputObject = { + itbl_anon_sessions: anonSessionInfo.itbl_anon_sessions + }; + + localStorage.setItem( + SHARED_PREFS_ANON_SESSIONS, + JSON.stringify(outputObject) + ); + } catch (error) { + console.error('Error updating anonymous session:', error); + } + } + + getAnonCriteria() { + const anonymousUsageTracked = isAnonymousUsageTracked(); + + if (!anonymousUsageTracked) return; + + baseIterableRequest({ + method: 'GET', + url: GET_CRITERIA_PATH, + data: {}, + validation: {} + }) + .then((response) => { + const criteriaData: any = response.data; + if (criteriaData) { + localStorage.setItem( + SHARED_PREFS_CRITERIA, + JSON.stringify(criteriaData) + ); + } + }) + .catch((e) => { + console.log('response', e); + }); + } + + async trackAnonEvent(payload: InAppTrackRequestParams) { + const newDataObject = { + [KEY_EVENT_NAME]: payload.eventName, + [KEY_CREATED_AT]: this.getCurrentTime(), + [KEY_DATA_FIELDS]: payload.dataFields, + [KEY_CREATE_NEW_FIELDS]: true, + [SHARED_PREFS_EVENT_TYPE]: TRACK_EVENT + }; + this.storeEventListToLocalStorage(newDataObject, false); + } + + async trackAnonUpdateUser(payload: UpdateUserParams) { + const newDataObject = { + ...payload.dataFields, + [SHARED_PREFS_EVENT_TYPE]: UPDATE_USER + }; + this.storeEventListToLocalStorage(newDataObject, true); + } + + async trackAnonPurchaseEvent(payload: TrackPurchaseRequestParams) { + const newDataObject = { + [KEY_ITEMS]: payload.items, + [KEY_CREATED_AT]: this.getCurrentTime(), + [KEY_DATA_FIELDS]: payload.dataFields, + [KEY_TOTAL]: payload.total, + [SHARED_PREFS_EVENT_TYPE]: TRACK_PURCHASE + }; + this.storeEventListToLocalStorage(newDataObject, false); + } + + async trackAnonUpdateCart(payload: UpdateCartRequestParams) { + const newDataObject = { + [KEY_ITEMS]: payload.items, + [SHARED_PREFS_EVENT_TYPE]: TRACK_UPDATE_CART, + [KEY_PREFER_USERID]: true, + [KEY_CREATED_AT]: this.getCurrentTime() + }; + this.storeEventListToLocalStorage(newDataObject, false); + } + + private checkCriteriaCompletion(): string | null { + const criteriaData = localStorage.getItem(SHARED_PREFS_CRITERIA); + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + try { + if (criteriaData && localStoredEventList) { + const checker = new CriteriaCompletionChecker(localStoredEventList); + return checker.getMatchedCriteria(criteriaData); + } + } catch (error) { + console.error('checkCriteriaCompletion', error); + } + + return null; + } + + private async createAnonymousUser(criteriaId: string) { + const anonymousUsageTracked = isAnonymousUsageTracked(); + + if (!anonymousUsageTracked) return; + + const userData = localStorage.getItem(SHARED_PREFS_ANON_SESSIONS); + const eventList = localStorage.getItem(SHARED_PREFS_EVENT_LIST_KEY); + const events = eventList ? JSON.parse(eventList) : []; + + const dataFields = { + ...events.find( + (event: any) => event[SHARED_PREFS_EVENT_TYPE] === UPDATE_USER + ) + }; + delete dataFields[SHARED_PREFS_EVENT_TYPE]; + + const userId = uuidv4(); + + if (userData) { + const userSessionInfo = JSON.parse(userData); + const userDataJson = userSessionInfo[SHARED_PREFS_ANON_SESSIONS]; + const payload: TrackAnonSessionParams = { + user: { + userId, + mergeNestedObjects: true, + createNewFields: true, + dataFields + }, + createdAt: this.getCurrentTime(), + deviceInfo: { + appPackageName: window.location.hostname, + deviceId: global.navigator.userAgent || '', + platform: WEB_PLATFORM + }, + anonSessionContext: { + totalAnonSessionCount: userDataJson.number_of_sessions, + lastAnonSession: userDataJson.last_session, + firstAnonSession: userDataJson.first_session, + matchedCriteriaId: parseInt(criteriaId, 10), + webPushOptIn: + this.getWebPushOptnIn() !== '' ? this.getWebPushOptnIn() : undefined + } + }; + const response = await baseIterableRequest({ + method: 'POST', + url: ENDPOINT_TRACK_ANON_SESSION, + data: payload + }).catch((e) => { + if (e?.response?.status === 409) { + this.getAnonCriteria(); + } + }); + if (response?.status === 200) { + // Update local storage, remove updateUser from local storage + localStorage.setItem( + SHARED_PREFS_EVENT_LIST_KEY, + JSON.stringify( + events.filter( + (event: any) => event[SHARED_PREFS_EVENT_TYPE] !== UPDATE_USER + ) + ) + ); + + const onAnonUserCreated = config.getConfig('onAnonUserCreated'); + + if (onAnonUserCreated) { + onAnonUserCreated(userId); + } + if (anonUserIdSetter !== null) { + await anonUserIdSetter(userId); + } + this.syncEvents(); + } + } + } + + async syncEvents() { + const strTrackEventList = localStorage.getItem(SHARED_PREFS_EVENT_LIST_KEY); + const trackEventList = strTrackEventList + ? JSON.parse(strTrackEventList) + : []; + + if (trackEventList.length) { + trackEventList.forEach( + ( + event: any /* eslint-disable-line @typescript-eslint/no-explicit-any */ + ) => { + const eventType = event[SHARED_PREFS_EVENT_TYPE]; + // eslint-disable-next-line no-param-reassign + delete event.eventType; + switch (eventType) { + case TRACK_EVENT: { + this.track(event); + break; + } + case TRACK_PURCHASE: { + this.trackPurchase(event); + break; + } + case TRACK_UPDATE_CART: { + this.updateCart(event); + break; + } + case UPDATE_USER: { + this.updateUser({ dataFields: event }); + break; + } + default: + break; + } + this.removeAnonSessionCriteriaData(); + } + ); + } + } + + removeAnonSessionCriteriaData() { + localStorage.removeItem(SHARED_PREFS_ANON_SESSIONS); + localStorage.removeItem(SHARED_PREFS_EVENT_LIST_KEY); + } + + private async storeEventListToLocalStorage( + newDataObject: Record< + any /* eslint-disable-line @typescript-eslint/no-explicit-any */, + any /* eslint-disable-line @typescript-eslint/no-explicit-any */ + >, + shouldOverWrite: boolean + ) { + const anonymousUsageTracked = isAnonymousUsageTracked(); + + if (!anonymousUsageTracked) return; + + const strTrackEventList = localStorage.getItem(SHARED_PREFS_EVENT_LIST_KEY); + let previousDataArray = []; + + if (strTrackEventList) { + previousDataArray = JSON.parse(strTrackEventList); + } + + if (shouldOverWrite) { + const trackingType = newDataObject[SHARED_PREFS_EVENT_TYPE]; + const indexToUpdate = previousDataArray.findIndex( + (obj: any) => obj[SHARED_PREFS_EVENT_TYPE] === trackingType + ); + if (indexToUpdate !== -1) { + const dataToUpdate = previousDataArray[indexToUpdate]; + + previousDataArray[indexToUpdate] = { + ...dataToUpdate, + ...newDataObject + }; + } else { + previousDataArray.push(newDataObject); + } + } else { + previousDataArray.push(newDataObject); + } + + // - The code below limits the number of events stored in local storage. + // - The event list acts as a queue, with the oldest events being deleted + // when new events are stored once the event threshold limit is reached. + + const eventThresholdLimit = + (config.getConfig('eventThresholdLimit') as number) ?? + DEFAULT_EVENT_THRESHOLD_LIMIT; + if (previousDataArray.length > eventThresholdLimit) { + previousDataArray = previousDataArray.slice( + previousDataArray.length - eventThresholdLimit + ); + } + + localStorage.setItem( + SHARED_PREFS_EVENT_LIST_KEY, + JSON.stringify(previousDataArray) + ); + const criteriaId = this.checkCriteriaCompletion(); + if (criteriaId !== null) { + this.createAnonymousUser(criteriaId); + } + } + + private getCurrentTime = () => { + const dateInMillis = new Date().getTime(); + const dateInSeconds = Math.floor(dateInMillis / 1000); + return dateInSeconds; + }; + + private getWebPushOptnIn(): string { + const notificationManager = window.Notification; + if (notificationManager && notificationManager.permission === 'granted') { + return window.location.hostname; + } + return ''; + } + + track = (payload: InAppTrackRequestParams) => + baseIterableRequest({ + method: 'POST', + url: ENDPOINTS.event_track.route, + data: payload, + validation: { + data: trackSchema + } + }); + + updateCart = (payload: UpdateCartRequestParams) => + baseIterableRequest({ + method: 'POST', + url: ENDPOINTS.commerce_update_cart.route, + data: { + ...payload, + user: { + ...payload.user, + preferUserId: true + } + }, + validation: { + data: updateCartSchema + } + }); + + trackPurchase = (payload: TrackPurchaseRequestParams) => + baseIterableRequest({ + method: 'POST', + url: ENDPOINTS.commerce_track_purchase.route, + data: { + ...payload, + user: { + ...payload.user, + preferUserId: true + } + }, + validation: { + data: trackPurchaseSchema + } + }); + + updateUser = (payload: UpdateUserParams = {}) => { + if (payload.dataFields) { + return baseIterableRequest({ + method: 'POST', + url: ENDPOINTS.users_update.route, + data: { + ...payload, + preferUserId: true + }, + validation: { + data: updateUserSchema + } + }); + } + return null; + }; +} diff --git a/src/anonymousUserTracking/anonymousUserMerge.ts b/src/anonymousUserTracking/anonymousUserMerge.ts new file mode 100644 index 00000000..0f26f902 --- /dev/null +++ b/src/anonymousUserTracking/anonymousUserMerge.ts @@ -0,0 +1,48 @@ +/* eslint-disable class-methods-use-this */ +import { ENDPOINT_MERGE_USER } from '../constants'; +import { baseIterableRequest } from '../request'; +import { IterableResponse } from '../types'; + +export type MergeApiParams = { + sourceEmail: string | null; + sourceUserId: string | null; + destinationEmail: string | null; + destinationUserId: string | null; +}; + +export class AnonymousUserMerge { + mergeUser( + sourceUserId: string | null, + sourceEmail: string | null, + destinationUserId: string | null, + destinationEmail: string | null + ): Promise { + const mergeApiParams: MergeApiParams = { + sourceUserId, + sourceEmail, + destinationUserId, + destinationEmail + }; + return this.callMergeApi(mergeApiParams); + } + + private callMergeApi(data: MergeApiParams): Promise { + return new Promise((resolve, reject) => { + baseIterableRequest({ + method: 'POST', + url: ENDPOINT_MERGE_USER, + data + }) + .then((response) => { + if (response.status === 200) { + resolve(); + } else { + reject(new Error(`merge error: ${response.status}`)); // Reject if status is not 200 + } + }) + .catch((e) => { + reject(e); // Reject the promise if the request fails + }); + }); + } +} diff --git a/src/anonymousUserTracking/complexCriteria.test.ts b/src/anonymousUserTracking/complexCriteria.test.ts new file mode 100644 index 00000000..8d8eb7cd --- /dev/null +++ b/src/anonymousUserTracking/complexCriteria.test.ts @@ -0,0 +1,1217 @@ +import { SHARED_PREFS_EVENT_LIST_KEY } from '../constants'; +import CriteriaCompletionChecker from './criteriaCompletionChecker'; + +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn() +}; + +describe('complexCriteria', () => { + beforeEach(() => { + (global as any).localStorage = localStorageMock; + }); + + // complex criteria + it('should return criteriaId 98 (complex criteria 1)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [ + { + id: '12', + name: 'monitor', + price: 50, + quantity: 10 + } + ], + total: 50, + eventType: 'purchase' + }, + { + dataFields: { + preferred_car_models: 'Honda', + country: 'Japan' + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '98', + name: 'Custom Event', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'eventName', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 23, + value: 'button.clicked' + }, + { + field: 'button-clicked.animal', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 25, + value: 'giraffe' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'updateCart.updatedShoppingCartItems.price', + fieldType: 'double', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 28, + value: '120' + }, + { + field: + 'updateCart.updatedShoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 29, + valueLong: 100, + value: '100' + } + ] + } + } + ] + }, + { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'shoppingCartItems.name', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'purchase', + id: 31, + value: 'monitor' + }, + { + field: 'shoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'purchase', + id: 32, + valueLong: 5, + value: '5' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'country', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'user', + id: 34, + value: 'Japan' + }, + { + field: 'preferred_car_models', + fieldType: 'string', + comparatorType: 'Contains', + dataType: 'user', + id: 36, + value: 'Honda' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('98'); + }); + + it('should return null (complex criteria 1 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [ + { + id: '12', + name: 'monitor', + price: 50, + quantity: 10 + } + ], + total: 50, + eventType: 'purchase' + }, + { + dataFields: { + preferred_car_models: 'Honda' + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '98', + name: 'Custom Event', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'eventName', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 23, + value: 'button.clicked' + }, + { + field: 'button-clicked.animal', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 25, + value: 'giraffe' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'updateCart.updatedShoppingCartItems.price', + fieldType: 'double', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 28, + value: '120' + }, + { + field: + 'updateCart.updatedShoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 29, + valueLong: 100, + value: '100' + } + ] + } + } + ] + }, + { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'shoppingCartItems.name', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'purchase', + id: 31, + value: 'monitor' + }, + { + field: 'shoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'purchase', + id: 32, + valueLong: 5, + value: '5' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'country', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'user', + id: 34, + value: 'Japan' + }, + { + field: 'preferred_car_models', + fieldType: 'string', + comparatorType: 'Contains', + dataType: 'user', + id: 36, + value: 'Honda' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 99 (complex criteria 2)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ id: '12', name: 'Mocha', price: 90, quantity: 50 }], + total: 50, + eventType: 'cartUpdate' + }, + { + items: [{ id: '12', name: 'Mocha', price: 90, quantity: 50 }], + total: 50, + eventType: 'cartUpdate' + }, + { + dataFields: { + preferred_car_models: 'Subaru', + country: 'USA' + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '99', + name: 'Custom Event', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'eventName', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 2, + value: 'button-clicked' + }, + { + field: 'button-clicked.lastPageViewed', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 4, + value: 'welcome page' + } + ] + } + }, + { + dataType: 'customEvent', + minMatch: 2, + maxMatch: 3, + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'updateCart.updatedShoppingCartItems.price', + fieldType: 'double', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 6, + value: '85' + }, + { + field: + 'updateCart.updatedShoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 7, + valueLong: 50, + value: '50' + } + ] + } + } + ] + }, + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'shoppingCartItems.name', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'purchase', + id: 16, + isFiltering: false, + value: 'coffee' + }, + { + field: 'shoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'purchase', + id: 17, + valueLong: 2, + value: '2' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'country', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'user', + id: 19, + value: 'USA' + }, + { + field: 'preferred_car_models', + fieldType: 'string', + comparatorType: 'Contains', + dataType: 'user', + id: 21, + value: 'Subaru' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('99'); + }); + + it('should return null (complex criteria 2 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ id: '12', name: 'Mocha', price: 90, quantity: 50 }], + total: 50, + eventType: 'cartUpdate' + }, + { + dataFields: { + preferred_car_models: 'Subaru', + country: 'USA' + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '99', + name: 'Custom Event', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'eventName', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 2, + value: 'button-clicked' + }, + { + field: 'button-clicked.lastPageViewed', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 4, + value: 'welcome page' + } + ] + } + }, + { + dataType: 'customEvent', + minMatch: 2, + maxMatch: 3, + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'updateCart.updatedShoppingCartItems.price', + fieldType: 'double', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 6, + value: '85' + }, + { + field: + 'updateCart.updatedShoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 7, + valueLong: 50, + value: '50' + } + ] + } + } + ] + }, + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'shoppingCartItems.name', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'purchase', + id: 16, + isFiltering: false, + value: 'coffee' + }, + { + field: 'shoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'purchase', + id: 17, + valueLong: 2, + value: '2' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'country', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'user', + id: 19, + value: 'USA' + }, + { + field: 'preferred_car_models', + fieldType: 'string', + comparatorType: 'Contains', + dataType: 'user', + id: 21, + value: 'Subaru' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 100 (complex criteria 3)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ id: '12', name: 'Mocha', price: 90, quantity: 50 }], + total: 50, + eventType: 'cartUpdate' + }, + { + items: [{ id: '12', name: 'Mocha', price: 90, quantity: 50 }], + total: 50, + eventType: 'cartUpdate' + }, + + { + dataFields: { + preferred_car_models: 'Subaru', + country: 'USA' + }, + eventType: 'user' + }, + { + eventName: 'button-clicked', + dataFields: { + lastPageViewed: 'welcome page' + }, + eventType: 'customEvent' + }, + { + items: [ + { + id: '12', + name: 'coffee', + price: 10, + quantity: 5 + } + ], + total: 2, + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '100', + name: 'Custom Event', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'eventName', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 2, + value: 'button-clicked' + }, + { + field: 'button-clicked.lastPageViewed', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 4, + value: 'welcome page' + } + ] + } + }, + { + dataType: 'customEvent', + minMatch: 2, + maxMatch: 3, + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'updateCart.updatedShoppingCartItems.price', + fieldType: 'double', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 6, + value: '85' + }, + { + field: + 'updateCart.updatedShoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 7, + valueLong: 50, + value: '50' + } + ] + } + }, + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'shoppingCartItems.name', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'purchase', + id: 9, + value: 'coffee' + }, + { + field: 'shoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'purchase', + id: 10, + valueLong: 2, + value: '2' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'country', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'user', + id: 12, + value: 'USA' + }, + { + field: 'preferred_car_models', + fieldType: 'string', + comparatorType: 'Contains', + dataType: 'user', + id: 14, + value: 'Subaru' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('100'); + }); + + it('should return null (complex criteria 3 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ id: '12', name: 'Mocha', price: 90, quantity: 50 }], + total: 50, + eventType: 'cartUpdate' + }, + { + items: [{ id: '12', name: 'Mocha', price: 90, quantity: 50 }], + total: 50, + eventType: 'cartUpdate' + }, + + { + dataFields: { + preferred_car_models: 'Subaru', + country: 'USA' + }, + eventType: 'user' + }, + { + eventName: 'button-clicked', + dataFields: { + 'button-clicked.lastPageViewed': 'welcome page' + }, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '100', + name: 'Custom Event', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'eventName', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 2, + value: 'button-clicked' + }, + { + field: 'button-clicked.lastPageViewed', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 4, + value: 'welcome page' + } + ] + } + }, + { + dataType: 'customEvent', + minMatch: 2, + maxMatch: 3, + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'updateCart.updatedShoppingCartItems.price', + fieldType: 'double', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 6, + value: '85' + }, + { + field: + 'updateCart.updatedShoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 7, + valueLong: 50, + value: '50' + } + ] + } + }, + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'shoppingCartItems.name', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'purchase', + id: 9, + value: 'coffee' + }, + { + field: 'shoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'purchase', + id: 10, + valueLong: 2, + value: '2' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'country', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'user', + id: 12, + value: 'USA' + }, + { + field: 'preferred_car_models', + fieldType: 'string', + comparatorType: 'Contains', + dataType: 'user', + id: 14, + value: 'Subaru' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 101 (complex criteria 4)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [ + { id: '12', name: 'sneakers', price: 10, quantity: 5 }, + { id: '13', name: 'slippers', price: 10, quantity: 3 } + ], + total: 2, + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '101', + name: 'Complex Criteria 4: (NOT 9) AND 10', + createdAt: 1719328083918, + updatedAt: 1719328083918, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Not', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'sneakers', + fieldType: 'string' + }, + { + dataType: 'purchase', + field: 'shoppingCartItems.quantity', + comparatorType: 'LessThanOrEqualTo', + value: '3', + fieldType: 'long' + } + ] + } + } + ] + }, + { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'slippers', + fieldType: 'string' + }, + { + dataType: 'purchase', + field: 'shoppingCartItems.quantity', + comparatorType: 'GreaterThanOrEqualTo', + value: '3', + fieldType: 'long' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('101'); + }); + + it('should return null (complex criteria 4 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [ + { id: '12', name: 'sneakers', price: 10, quantity: 2 }, + { id: '13', name: 'slippers', price: 10, quantity: 3 } + ], + total: 2, + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '101', + name: 'Complex Criteria 4: (NOT 9) AND 10', + createdAt: 1719328083918, + updatedAt: 1719328083918, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Not', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'sneakers', + fieldType: 'string' + }, + { + dataType: 'purchase', + field: 'shoppingCartItems.quantity', + comparatorType: 'LessThanOrEqualTo', + value: '3', + fieldType: 'long' + } + ] + } + } + ] + }, + { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'slippers', + fieldType: 'string' + }, + { + dataType: 'purchase', + field: 'shoppingCartItems.quantity', + comparatorType: 'GreaterThanOrEqualTo', + value: '3', + fieldType: 'long' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); +}); diff --git a/src/anonymousUserTracking/criteriaCompletionChecker.ts b/src/anonymousUserTracking/criteriaCompletionChecker.ts new file mode 100644 index 00000000..82ba844c --- /dev/null +++ b/src/anonymousUserTracking/criteriaCompletionChecker.ts @@ -0,0 +1,610 @@ +/* eslint-disable class-methods-use-this */ +import { + SHARED_PREFS_EVENT_TYPE, + KEY_ITEMS, + TRACK_PURCHASE, + TRACK_UPDATE_CART, + TRACK_EVENT, + UPDATE_CART, + UPDATE_USER, + KEY_EVENT_NAME, + UPDATECART_ITEM_PREFIX, + PURCHASE_ITEM_PREFIX, + PURCHASE_ITEM +} from '../constants'; + +interface SearchQuery { + combinator: string; + /* eslint-disable-next-line no-use-before-define */ + searchQueries: SearchQuery[] | Criteria[]; + dataType?: string; + searchCombo?: SearchQuery; + field?: string; + comparatorType?: string; + value?: string; + fieldType?: string; + minMatch?: number; + maxMatch?: number; +} + +interface Criteria { + criteriaId: string; + name: string; + createdAt: number; + updatedAt: number; + searchQuery: SearchQuery; +} + +class CriteriaCompletionChecker { + private localStoredEventList: any[]; + + constructor(localStoredEventList: string) { + this.localStoredEventList = JSON.parse(localStoredEventList); + } + + public getMatchedCriteria(criteriaData: string): string | null { + let criteriaId: string | null = null; + + try { + const json = JSON.parse(criteriaData); + if (json.criteriaSets) { + criteriaId = this.findMatchedCriteria(json.criteriaSets); + } + } catch (e) { + this.handleJSONException(e); + } + + return criteriaId; + } + + private findMatchedCriteria(criteriaList: Criteria[]): string | null { + const eventsToProcess = this.prepareEventsToProcess(); + + // Use find to get the first matching criteria + const matchingCriteria = criteriaList.find((criteria) => { + if (criteria.searchQuery && criteria.criteriaId) { + return this.evaluateTree(criteria.searchQuery, eventsToProcess); + } + return false; + }); + + // Return the criteriaId of the matching criteria or null if none found + return matchingCriteria ? matchingCriteria.criteriaId : null; + } + + private prepareEventsToProcess(): any[] { + const eventsToProcess: any[] = this.getEventsWithCartItems(); + const nonPurchaseEvents: any[] = this.getNonCartEvents(); + + nonPurchaseEvents.forEach((event) => { + eventsToProcess.push(event); + }); + + return eventsToProcess; + } + + private getEventsWithCartItems(): any[] { + const processedEvents: any[] = []; + + this.localStoredEventList.forEach((localEventData) => { + if ( + localEventData[SHARED_PREFS_EVENT_TYPE] && + localEventData[SHARED_PREFS_EVENT_TYPE] === TRACK_PURCHASE + ) { + const updatedItem: Record = {}; + + if (localEventData[KEY_ITEMS]) { + let items = localEventData[KEY_ITEMS]; + items = items.map((item: any) => { + const updatItem: any = {}; + Object.keys(item).forEach((key) => { + updatItem[`${PURCHASE_ITEM_PREFIX}${key}`] = item[key]; + }); + return updatItem; + }); + updatedItem[PURCHASE_ITEM] = items; + } + + if (localEventData.dataFields) { + Object.keys(localEventData.dataFields).forEach((key) => { + updatedItem[key] = localEventData.dataFields[key]; + }); + } + + Object.keys(localEventData).forEach((key) => { + if (key !== KEY_ITEMS && key !== 'dataFields') { + updatedItem[key] = localEventData[key]; + } + }); + processedEvents.push({ + ...updatedItem, + [SHARED_PREFS_EVENT_TYPE]: TRACK_PURCHASE + }); + } else if ( + localEventData[SHARED_PREFS_EVENT_TYPE] && + localEventData[SHARED_PREFS_EVENT_TYPE] === TRACK_UPDATE_CART + ) { + const updatedItem: any = {}; + + if (localEventData[KEY_ITEMS]) { + let items = localEventData[KEY_ITEMS]; + items = items.map((item: any) => { + const updatItem: any = {}; + Object.keys(item).forEach((key) => { + updatItem[`${UPDATECART_ITEM_PREFIX}${key}`] = item[key]; + }); + return updatItem; + }); + updatedItem[KEY_ITEMS] = items; + } + + if (localEventData.dataFields) { + Object.keys(localEventData.dataFields).forEach((key) => { + updatedItem[key] = localEventData.dataFields[key]; + }); + // eslint-disable-next-line no-param-reassign + delete localEventData.dataFields; + } + Object.keys(localEventData).forEach((key) => { + if (key !== KEY_ITEMS && key !== 'dataFields') { + if (key === SHARED_PREFS_EVENT_TYPE) { + updatedItem[key] = TRACK_EVENT; + } else { + updatedItem[key] = localEventData[key]; + } + } + }); + processedEvents.push({ + ...updatedItem, + [KEY_EVENT_NAME]: UPDATE_CART, + [SHARED_PREFS_EVENT_TYPE]: TRACK_EVENT + }); + } + }); + return processedEvents; + } + + private getNonCartEvents(): any[] { + const nonPurchaseEvents: any[] = []; + this.localStoredEventList.forEach((localEventData) => { + if ( + localEventData[SHARED_PREFS_EVENT_TYPE] && + (localEventData[SHARED_PREFS_EVENT_TYPE] === UPDATE_USER || + localEventData[SHARED_PREFS_EVENT_TYPE] === TRACK_EVENT) + ) { + const updatedItem: any = localEventData; + if (localEventData.dataFields) { + Object.keys(localEventData.dataFields).forEach((key) => { + updatedItem[key] = localEventData.dataFields[key]; + }); + // eslint-disable-next-line no-param-reassign + delete localEventData.dataFields; + } + nonPurchaseEvents.push(updatedItem); + } + }); + return nonPurchaseEvents; + } + + private evaluateTree( + node: SearchQuery | Criteria, + localEventData: any[] + ): boolean { + try { + if ((node as SearchQuery).searchQueries) { + const { combinator } = node as SearchQuery; + const { searchQueries } = node as SearchQuery; + if (combinator === 'And') { + /* eslint-disable-next-line @typescript-eslint/prefer-for-of */ + for (let i = 0; i < searchQueries.length; i += 1) { + if (!this.evaluateTree(searchQueries[i], localEventData)) { + return false; + } + } + return true; + } + if (combinator === 'Or') { + /* eslint-disable-next-line @typescript-eslint/prefer-for-of */ + for (let i = 0; i < searchQueries.length; i += 1) { + if (this.evaluateTree(searchQueries[i], localEventData)) { + return true; + } + } + return false; + } + if (combinator === 'Not') { + /* eslint-disable-next-line @typescript-eslint/prefer-for-of */ + for (let i = 0; i < searchQueries.length; i += 1) { + (searchQueries[i] as any).isNot = true; + if (this.evaluateTree(searchQueries[i], localEventData)) { + return false; + } + } + return true; + } + } else if ((node as SearchQuery).searchCombo) { + return this.evaluateSearchQueries(node as SearchQuery, localEventData); + } + } catch (e) { + this.handleException(e); + } + return false; + } + + /* eslint-disable no-continue */ + private evaluateSearchQueries( + node: SearchQuery, + localEventData: any[] + ): boolean { + // this function will compare the actualy searhqueues under search combo + for (let i = 0; i < localEventData.length; i += 1) { + const eventData = localEventData[i]; + const trackingType = eventData[SHARED_PREFS_EVENT_TYPE]; + const { dataType } = node; + if (dataType === trackingType) { + const { searchCombo } = node; + const searchQueries = searchCombo?.searchQueries || []; + const combinator = searchCombo?.combinator || ''; + const isNot = Object.prototype.hasOwnProperty.call(node, 'isNot'); + if (this.evaluateEvent(eventData, searchQueries, combinator)) { + if (node.minMatch) { + const minMatch = node.minMatch - 1; + // eslint-disable-next-line no-param-reassign + node.minMatch = minMatch; + if (minMatch > 0) { + continue; + } + } + if (isNot && !(i + 1 === localEventData.length)) { + continue; + } + return true; + } + if (isNot) { + return false; + } + } + } + return false; + } + + private evaluateEvent( + localEvent: any, + searchQueries: any, + combinator: string + ): boolean { + if (combinator === 'And' || combinator === 'Or') { + return this.evaluateFieldLogic(searchQueries, localEvent); + } + if (combinator === 'Not') { + return !this.evaluateFieldLogic(searchQueries, localEvent); + } + return false; + } + + private doesItemCriteriaExists(searchQueries: any[]): boolean { + const foundIndex = searchQueries.findIndex( + (item) => + item.field.startsWith(UPDATECART_ITEM_PREFIX) || + item.field.startsWith(PURCHASE_ITEM_PREFIX) + ); + return foundIndex !== -1; + } + + private evaluateFieldLogic(searchQueries: any[], eventData: any): boolean { + const localDataKeys = Object.keys(eventData); + let itemMatchedResult = false; + let keyItem = null; + if (localDataKeys.includes(KEY_ITEMS)) { + keyItem = KEY_ITEMS; + } else if (localDataKeys.includes(PURCHASE_ITEM)) { + keyItem = PURCHASE_ITEM; + } + + if (keyItem !== null) { + // scenario of items inside purchase and updateCart Events + const items = eventData[keyItem]; + const result = items.some((item: any) => + this.doesItemMatchQueries(item, searchQueries) + ); + if (!result && this.doesItemCriteriaExists(searchQueries)) { + // items criteria existed and it did not match + return result; + } + itemMatchedResult = result; + } + const filteredLocalDataKeys = localDataKeys.filter( + (item: any) => item !== KEY_ITEMS + ); + + if (filteredLocalDataKeys.length === 0) { + return itemMatchedResult; + } + + const filteredSearchQueries = searchQueries.filter( + (searchQuery) => + !searchQuery.field.startsWith(UPDATECART_ITEM_PREFIX) && + !searchQuery.field.startsWith(PURCHASE_ITEM_PREFIX) + ); + if (filteredSearchQueries.length === 0) { + return itemMatchedResult; + } + const matchResult = filteredSearchQueries.every((query: any) => { + const { field } = query; + if ( + query.dataType === TRACK_EVENT && + query.fieldType === 'object' && + query.comparatorType === 'IsSet' + ) { + const eventName = eventData[KEY_EVENT_NAME]; + if (eventName === UPDATE_CART && field === eventName) { + return true; + } + if (field === eventName) { + return true; + } + } + + const eventKeyItems = filteredLocalDataKeys.filter( + (keyItem) => keyItem === field + ); + + if (field.includes('.')) { + const splitField = field.split('.') as string[]; + const fields = + eventData?.eventType === TRACK_EVENT && + eventData?.eventName === splitField[0] + ? splitField.slice(1) + : splitField; + + let fieldValue = eventData; + let isSubFieldArray = false; + let isSubMatch = false; + + fields.forEach((subField) => { + const subFieldValue = fieldValue[subField]; + if ( + Array.isArray(subFieldValue) && + subFieldValue?.[0] instanceof Object + ) { + isSubFieldArray = true; + isSubMatch = subFieldValue.some((item: any) => { + const data = fields.reduceRight((acc: any, key) => { + if (key === subField) { + return { [key]: item }; + } + return { [key]: acc }; + }, {}); + + return this.evaluateFieldLogic(searchQueries, { + ...eventData, + ...data + }); + }); + } else if (subFieldValue instanceof Object) { + fieldValue = subFieldValue; + } + }); + + if (isSubFieldArray) { + return isSubMatch; + } + + const valueFromObj = this.getFieldValue(eventData, field); + if (valueFromObj) { + return this.evaluateComparison( + query.comparatorType, + valueFromObj, + query.value ?? query.values ?? '' + ); + } + } else if (eventKeyItems.length) { + return this.evaluateComparison( + query.comparatorType, + eventData[field], + query.value ?? query.values ?? '' + ); + } + return false; + }); + return matchResult; + } + + private getFieldValue(data: any, field: string): any { + const fields: string[] = field.split('.'); + if (data?.eventType === TRACK_EVENT && data?.eventName === fields[0]) { + fields.shift(); + } + return fields.reduce( + (value, currentField) => + value && value[currentField] !== undefined + ? value[currentField] + : undefined, + data + ); + } + + private doesItemMatchQueries(item: any, searchQueries: any[]): boolean { + let shouldReturn = false; + const filteredSearchQueries = searchQueries.filter((searchQuery) => { + if ( + searchQuery.field.startsWith(UPDATECART_ITEM_PREFIX) || + searchQuery.field.startsWith(PURCHASE_ITEM_PREFIX) + ) { + if (!Object.keys(item).includes(searchQuery.field)) { + shouldReturn = true; + return false; + } + return true; + } + return false; + }); + if (filteredSearchQueries.length === 0 || shouldReturn) { + return false; + } + return filteredSearchQueries.every((query: any) => { + const { field } = query; + if (Object.prototype.hasOwnProperty.call(item, field)) { + return this.evaluateComparison( + query.comparatorType, + item[field], + query.value ?? query.values ?? '' + ); + } + return false; + }); + } + + private evaluateComparison( + comparatorType: string, + matchObj: any, + valueToCompare: string | string[] + ): boolean { + if (!valueToCompare && comparatorType !== 'IsSet') { + return false; + } + switch (comparatorType) { + case 'Equals': + return this.compareValueEquality(matchObj, valueToCompare); + case 'DoesNotEqual': + return !this.compareValueEquality(matchObj, valueToCompare); + case 'IsSet': + return this.issetCheck(matchObj); + case 'GreaterThan': + case 'LessThan': + case 'GreaterThanOrEqualTo': + case 'LessThanOrEqualTo': + return this.compareNumericValues( + matchObj, + valueToCompare as string, + comparatorType + ); + case 'Contains': + return this.compareStringContains(matchObj, valueToCompare as string); + case 'StartsWith': + return this.compareStringStartsWith(matchObj, valueToCompare as string); + case 'MatchesRegex': + return this.compareWithRegex(matchObj, valueToCompare as string); + default: + return false; + } + } + + private compareValueEquality( + sourceTo: any, + stringValue: string | string[] + ): boolean { + if (Array.isArray(sourceTo)) { + return sourceTo.some((source) => + this.compareValueEquality(source, stringValue) + ); + } + + if (Array.isArray(stringValue)) { + return stringValue.some((value) => + this.compareValueEquality(sourceTo, value) + ); + } + + if ( + (typeof sourceTo === 'number' || typeof sourceTo === 'boolean') && + stringValue !== '' + ) { + // eslint-disable-next-line no-restricted-globals + if (typeof sourceTo === 'number' && !isNaN(parseFloat(stringValue))) { + return sourceTo === parseFloat(stringValue); + } + if (typeof sourceTo === 'boolean') { + return sourceTo === (stringValue === 'true'); + } + } else if (typeof sourceTo === 'string') { + return sourceTo === stringValue; + } + return false; + } + + private compareNumericValues( + sourceTo: any, + stringValue: string, + compareOperator: string + ): boolean { + // eslint-disable-next-line no-restricted-globals + if (Array.isArray(sourceTo)) { + return sourceTo.some((source) => + this.compareNumericValues(source, stringValue, compareOperator) + ); + } + + if (!Number.isNaN(parseFloat(stringValue))) { + const sourceNumber = parseFloat(sourceTo); + const numericValue = parseFloat(stringValue); + switch (compareOperator) { + case 'GreaterThan': + return sourceNumber > numericValue; + case 'LessThan': + return sourceNumber < numericValue; + case 'GreaterThanOrEqualTo': + return sourceNumber >= numericValue; + case 'LessThanOrEqualTo': + return sourceNumber <= numericValue; + default: + return false; + } + } + return false; + } + + private compareStringContains(sourceTo: any, stringValue: string): boolean { + if (Array.isArray(sourceTo)) { + return sourceTo.some((source) => + this.compareStringContains(source, stringValue) + ); + } + return ( + (typeof sourceTo === 'string' || typeof sourceTo === 'object') && + sourceTo.includes(stringValue) + ); + } + + private compareStringStartsWith(sourceTo: any, stringValue: string): boolean { + if (Array.isArray(sourceTo)) { + return sourceTo.some((source) => + this.compareStringStartsWith(source, stringValue) + ); + } + return typeof sourceTo === 'string' && sourceTo.startsWith(stringValue); + } + + private compareWithRegex(sourceTo: string, pattern: string): boolean { + if (Array.isArray(sourceTo)) { + return sourceTo.some((source) => this.compareWithRegex(source, pattern)); + } + try { + const regexPattern = new RegExp(pattern); + return regexPattern.test(sourceTo); + } catch (e) { + console.error(e); + return false; + } + } + + private issetCheck(matchObj: string | object | any[]): boolean { + if (Array.isArray(matchObj)) { + return matchObj.length > 0; + } + if (typeof matchObj === 'object' && matchObj !== null) { + return Object.keys(matchObj).length > 0; + } + return matchObj !== ''; + } + + private handleException(e: any) { + console.error('Exception occurred', e.toString()); + } + + private handleJSONException(e: any) { + console.error('JSONException occurred', e.toString()); + } +} + +export default CriteriaCompletionChecker; diff --git a/src/anonymousUserTracking/tests/anonymousUserEventManager.test.ts b/src/anonymousUserTracking/tests/anonymousUserEventManager.test.ts new file mode 100644 index 00000000..50d60020 --- /dev/null +++ b/src/anonymousUserTracking/tests/anonymousUserEventManager.test.ts @@ -0,0 +1,519 @@ +import { AnonymousUserEventManager } from '../anonymousUserEventManager'; +import { baseIterableRequest } from '../../request'; +import { + SHARED_PREFS_ANON_SESSIONS, + SHARED_PREFS_EVENT_LIST_KEY, + SHARED_PREFS_CRITERIA, + SHARED_PREF_ANON_USAGE_TRACKED +} from '../../constants'; +import { UpdateUserParams } from '../../users'; +import { TrackPurchaseRequestParams } from '../../commerce'; + +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn() +}; + +jest.mock('../criteriaCompletionChecker', () => + jest.fn().mockImplementation(() => ({ + getMatchedCriteria: jest.fn() + })) +); + +jest.mock('../../request', () => ({ + baseIterableRequest: jest.fn() +})); + +declare global { + function uuidv4(): string; + function getEmail(): string; + function getUserID(): string; + function setUserID(): string; +} + +describe('AnonymousUserEventManager', () => { + let anonUserEventManager: AnonymousUserEventManager; + + beforeEach(() => { + (global as any).localStorage = localStorageMock; + anonUserEventManager = new AnonymousUserEventManager(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should update anonymous session information correctly', () => { + const initialAnonSessionInfo = { + itbl_anon_sessions: { + number_of_sessions: 1, + first_session: 123456789, + last_session: expect.any(Number) + } + }; + + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_ANON_SESSIONS) { + return JSON.stringify(initialAnonSessionInfo); + } + if (key === SHARED_PREF_ANON_USAGE_TRACKED) { + return 'true'; + } + return null; + }); + + anonUserEventManager.updateAnonSession(); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + SHARED_PREFS_ANON_SESSIONS, + expect.stringContaining('itbl_anon_sessions') + ); + }); + + it('should set criteria data in localStorage when baseIterableRequest succeeds', async () => { + const mockResponse = { data: { criteria: 'mockCriteria' } }; + (baseIterableRequest as jest.Mock).mockResolvedValueOnce(mockResponse); + + const setItemMock = jest.spyOn(localStorage, 'setItem'); + await anonUserEventManager.getAnonCriteria(); + + expect(setItemMock).toHaveBeenCalledWith( + SHARED_PREFS_CRITERIA, + '{"criteria":"mockCriteria"}' + ); + }); + + it('should create known user and make API request when userData is available', async () => { + const userData = { + number_of_sessions: 5, + last_session: 123456789, + first_session: 123456789 + }; + + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === 'criteria') { + return JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'EventCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'testEvent', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }); + } + if (key === SHARED_PREFS_ANON_SESSIONS) { + return JSON.stringify(userData); + } + return null; + }); + + anonUserEventManager.updateAnonSession(); + }); + + it('should call createAnonymousUser when trackAnonEvent is called', async () => { + const payload = { + eventName: 'testEvent', + eventType: 'customEvent' + }; + const eventData = [ + { + eventName: 'testEvent', + createdAt: 1708494757530, + dataFields: undefined, + createNewFields: true, + eventType: 'customEvent' + } + ]; + + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === 'criteria') { + return JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'EventCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'testEvent', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }); + } + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify(eventData); + } + return null; + }); + await anonUserEventManager.trackAnonEvent(payload); + }); + + it('should not call createAnonymousUser when trackAnonEvent is called and criteria does not match', async () => { + const payload = { + eventName: 'Event' + }; + const eventData = [ + { + eventName: 'Event', + createdAt: 1708494757530, + dataFields: undefined, + createNewFields: true, + eventType: 'customEvent' + } + ]; + + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === 'criteria') { + return JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'EventCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'testEvent', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }); + } + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify(eventData); + } + return null; + }); + await anonUserEventManager.trackAnonEvent(payload); + }); + + it('should not call createAnonymousUser when trackAnonEvent is called and criteria not find', async () => { + const payload = { + eventName: 'Event' + }; + const eventData = [ + { + eventName: 'Event', + createdAt: 1708494757530, + dataFields: undefined, + createNewFields: true, + eventType: 'customEvent' + } + ]; + + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === 'criteria') { + return null; + } + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify(eventData); + } + return null; + }); + await anonUserEventManager.trackAnonEvent(payload); + }); + + it('should call createAnonymousUser when trackAnonUpdateUser is called', async () => { + const payload: UpdateUserParams = { + dataFields: { country: 'UK' } + }; + const userData = [ + { + userId: 'user', + createdAt: 1708494757530, + dataFields: undefined, + createNewFields: true, + eventType: 'updateUser' + } + ]; + + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === 'criteria') { + return JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'UpdateUserCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'updateUser', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'updateUser', + field: 'userId', + comparatorType: 'Equals', + value: 'user', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }); + } + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify(userData); + } + return null; + }); + await anonUserEventManager.trackAnonUpdateUser(payload); + }); + + it('should call createAnonymousUser when trackAnonPurchaseEvent is called', async () => { + const payload: TrackPurchaseRequestParams = { + items: [ + { + id: '123', + name: 'Black Coffee', + quantity: 1, + price: 4 + } + ], + total: 0 + }; + const userData = [ + { + items: [ + { + id: '123', + name: 'Black Coffee', + quantity: 1, + price: 4 + } + ], + user: { + userId: 'user' + }, + total: 0, + eventType: 'purchase' + } + ]; + + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === 'criteria') { + return JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'shoppingCartItemsCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'Black Coffee', + fieldType: 'string' + }, + { + dataType: 'purchase', + field: 'shoppingCartItems.price', + comparatorType: 'GreaterThanOrEqualTo', + value: '4.00', + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] + }); + } + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify(userData); + } + return null; + }); + await anonUserEventManager.trackAnonPurchaseEvent(payload); + }); + + it('should call createAnonymousUser when trackAnonUpdateCart is called', async () => { + const payload: TrackPurchaseRequestParams = { + items: [ + { + id: '123', + name: 'Black Coffee', + quantity: 2, + price: 4 + } + ], + total: 0 + }; + const userData = [ + { + items: [ + { + id: '123', + name: 'Black Coffee', + quantity: 1, + price: 4 + } + ], + user: { + userId: 'user' + }, + total: 0, + eventType: 'cartUpdate' + } + ]; + + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === 'criteria') { + return JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'CartUpdateItemsCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'cartUpdate', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'cartUpdate', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'Black Coffee', + fieldType: 'string' + }, + { + dataType: 'cartUpdate', + field: 'shoppingCartItems.price', + comparatorType: 'GreaterThanOrEqualTo', + value: '4.00', + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] + }); + } + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify(userData); + } + return null; + }); + await anonUserEventManager.trackAnonUpdateCart(payload); + }); +}); diff --git a/src/anonymousUserTracking/tests/combinationLogicCriteria.test.ts b/src/anonymousUserTracking/tests/combinationLogicCriteria.test.ts new file mode 100644 index 00000000..17d339bb --- /dev/null +++ b/src/anonymousUserTracking/tests/combinationLogicCriteria.test.ts @@ -0,0 +1,1985 @@ +import { SHARED_PREFS_EVENT_LIST_KEY } from '../../constants'; +import CriteriaCompletionChecker from '../criteriaCompletionChecker'; + +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn() +}; + +describe('CombinationLogicCriteria', () => { + beforeEach(() => { + (global as any).localStorage = localStorageMock; + }); + + it('should return criteriaId 1 if Contact Property AND Custom Event is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { total: 10 }, + eventType: 'customEvent' + }, + { + dataFields: { firstName: 'David' }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '1', + name: 'Combination Logic - Contact Property AND Custom Event', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'Equals', + value: 'David', + id: 2, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'total', + comparatorType: 'Equals', + value: '10', + id: 6, + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('1'); + }); + + it('should return null (combination logic criteria 1 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { total: 10 }, + eventType: 'customEvent' + }, + { + dataFields: { firstName: 'Davidson' }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '1', + name: 'Combination Logic - Contact Property AND Custom Event', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'Equals', + value: 'David', + id: 2, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'total', + comparatorType: 'Equals', + value: '10', + id: 6, + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 2 if Contact Property OR Custom Event is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { total: 10 }, + eventType: 'customEvent' + }, + { + dataFields: { firstName: 'David' }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '2', + name: 'Combination Logic - Contact Property OR Custom Event', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'Equals', + value: 'David', + id: 2, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'total', + comparatorType: 'Equals', + value: '10', + id: 6, + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('2'); + }); + + it('should return null (combination logic criteria 2 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { total: 101 }, + eventType: 'customEvent' + }, + { + dataFields: { firstName: 'Davidson' }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '2', + name: 'Combination Logic - Contact Property OR Custom Event', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'Equals', + value: 'David', + id: 2, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'total', + comparatorType: 'Equals', + value: '10', + id: 6, + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 3 if Contact Property NOR (NOT) Custom Event is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + itesm: [{ name: 'Cofee', id: 'fdsafds', price: 10, quantity: 2 }], + total: 10 + }, + eventType: 'customEvent' + }, + { + dataFields: { firstName: 'Davidson' }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '3', + name: 'Combination Logic - Contact Property NOR (NOT) Custom Event', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Not', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'total', + comparatorType: 'Equals', + value: '10', + id: 6, + fieldType: 'double' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'Equals', + value: 'David', + id: 2, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('3'); + }); + + it('should return null (combination logic criteria 3 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { total: 1 }, + eventType: 'customEvent' + }, + { + dataFields: { firstName: 'David' }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '3', + name: 'Combination Logic - Contact Property NOR (NOT) Custom Event', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Not', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'Equals', + value: 'David', + id: 2, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'total', + comparatorType: 'Equals', + value: '10', + id: 6, + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 4 if UpdateCart AND Contact Property is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ name: 'fried', id: 'fried', price: 10, quantity: 2 }], + eventType: 'cartUpdate' + }, + { + dataFields: { firstName: 'David' }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '4', + name: 'Combination Logic - UpdateCart AND Contact Property', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.name', + comparatorType: 'Equals', + value: 'fried', + id: 2, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'Equals', + value: 'David', + id: 2, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('4'); + }); + + it('should return null (combination logic criteria 4 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ name: 'chicken', id: 'chicken', price: 10, quantity: 2 }], + eventType: 'cartUpdate' + }, + { + dataFields: { firstName: 'Davidson' }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '4', + name: 'Combination Logic - UpdateCart AND Contact Property', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.name', + comparatorType: 'Equals', + value: 'fried', + id: 2, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'Equals', + value: 'David', + id: 2, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 5 if UpdateCart OR Contact Property is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ name: 'fried', id: 'fried', price: 10, quantity: 2 }], + eventType: 'cartUpdate' + }, + { + dataFields: { firstName: 'David' }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '5', + name: 'Combination Logic - UpdateCart OR Contact Property', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.name', + comparatorType: 'Equals', + value: 'fried', + id: 2, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'Equals', + value: 'David', + id: 2, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('5'); + }); + + it('should return null (combination logic criteria 5 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ name: 'chicken', id: 'chicken', price: 10, quantity: 2 }], + eventType: 'cartUpdate' + }, + { + dataFields: { firstName: 'Davidson' }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '5', + name: 'Combination Logic - UpdateCart OR Contact Property', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.name', + comparatorType: 'Equals', + value: 'fried', + id: 2, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'Equals', + value: 'David', + id: 2, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 6 if UpdateCart NOR (NOT) Contact Property is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ name: 'boiled', id: 'boiled', price: 10, quantity: 2 }], + eventType: 'cartUpdate' + }, + { + dataFields: { firstName: 'Davidson' }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'Combination Logic - UpdateCart NOR (NOT) Contact Property', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Not', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.name', + comparatorType: 'Equals', + value: 'fried', + id: 2, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'Equals', + value: 'David', + id: 2, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('6'); + }); + + it('should return null (combination logic criteria 6 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ name: 'fried', id: 'fried', price: 10, quantity: 2 }], + eventType: 'cartUpdate' + }, + { + dataFields: { firstName: 'David' }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'Combination Logic - UpdateCart NOR (NOT) Contact Property', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Not', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.name', + comparatorType: 'Equals', + value: 'fried', + id: 2, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'Equals', + value: 'David', + id: 2, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 7 if Purchase AND UpdateCart is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ name: 'chicken', id: 'chicken', price: 10, quantity: 2 }], + eventType: 'purchase' + }, + { + items: [{ name: 'fried', id: 'fried', price: 10, quantity: 2 }], + eventType: 'cartUpdate' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '7', + name: 'Combination Logic - Purchase AND UpdateCart', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'chicken', + id: 13, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.name', + comparatorType: 'Equals', + value: 'fried', + id: 2, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('7'); + }); + + it('should return null (combination logic criteria 7 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ name: 'fried', id: 'fried', price: 10, quantity: 2 }], + eventType: 'purchase' + }, + { + items: [{ name: 'chicken', id: 'chicken', price: 10, quantity: 2 }], + eventType: 'cartUpdate' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '7', + name: 'Combination Logic - Purchase AND UpdateCart', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'chicken', + id: 13, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.name', + comparatorType: 'Equals', + value: 'fried', + id: 2, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 8 if Purchase OR UpdateCart is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ name: 'chicken', id: 'chicken', price: 10, quantity: 2 }], + eventType: 'purchase' + }, + { + items: [{ name: 'fried', id: 'fried', price: 10, quantity: 2 }], + eventType: 'cartUpdate' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '8', + name: 'Combination Logic - Purchase OR UpdateCart', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'chicken', + id: 13, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.name', + comparatorType: 'Equals', + value: 'fried', + id: 2, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('8'); + }); + + it('should return null (combination logic criteria 8 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ name: 'fried', id: 'fried', price: 10, quantity: 2 }], + eventType: 'purchase' + }, + { + items: [{ name: 'chicken', id: 'chicken', price: 10, quantity: 2 }], + eventType: 'cartUpdate' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '8', + name: 'Combination Logic - Purchase OR UpdateCart', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'chicken', + id: 13, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.name', + comparatorType: 'Equals', + value: 'fried', + id: 2, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 9 if Purchase NOR (NOT) UpdateCart is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ name: 'beef', id: 'beef', price: 10, quantity: 2 }], + eventType: 'purchase' + }, + { + items: [{ name: 'boiled', id: 'boiled', price: 10, quantity: 2 }], + eventType: 'cartUpdate' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '9', + name: 'Combination Logic - Purchase NOR (NOT) UpdateCart', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Not', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'chicken', + id: 13, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.name', + comparatorType: 'Equals', + value: 'fried', + id: 2, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('9'); + }); + + it('should return null (combination logic criteria 9 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ name: 'chicken', id: 'chicken', price: 10, quantity: 2 }], + eventType: 'purchase' + }, + { + items: [{ name: 'fried', id: 'fried', price: 10, quantity: 2 }], + eventType: 'cartUpdate' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '9', + name: 'Combination Logic - Purchase NOR (NOT) UpdateCart', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Not', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'chicken', + id: 13, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.name', + comparatorType: 'Equals', + value: 'fried', + id: 2, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 10 if Custom Event AND Purchase is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { eventName: 'birthday' }, + eventType: 'customEvent' + }, + { + items: [{ name: 'chicken', id: 'chicken', price: 10, quantity: 2 }], + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '10', + name: 'Combination Logic - Custom Event AND Purchase', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'chicken', + id: 13, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'birthday', + id: 16, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('10'); + }); + + it('should return null (combination logic criteria 10 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { eventName: 'anniversary' }, + eventType: 'customEvent' + }, + { + items: [{ name: 'fried', id: 'fried', price: 10, quantity: 2 }], + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '10', + name: 'Combination Logic - Custom Event AND Purchase', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'chicken', + id: 13, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'birthday', + id: 16, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 11 if Custom Event OR Purchase is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { eventName: 'birthday' }, + eventType: 'customEvent' + } + /* { + items: [{ name: 'chicken', id: 'chicken', price: 10, quantity: 2 }], + eventType: 'purchase' + } */ + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '11', + name: 'Combination Logic - Custom Event OR Purchase', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'chicken', + id: 13, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'birthday', + id: 16, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('11'); + }); + + it('should return null (combination logic criteria 11 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { eventName: 'anniversary' }, + eventType: 'customEvent' + }, + { + items: [{ name: 'fried', id: 'fried', price: 10, quantity: 2 }], + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '11', + name: 'Combination Logic - Custom Event OR Purchase', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'chicken', + id: 13, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'birthday', + id: 16, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 12 if Custom Event NOR (NOT) Purchase is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { eventName: 'anniversary' }, + eventType: 'customEvent' + }, + { + items: [{ name: 'beef', id: 'beef', price: 10, quantity: 2 }], + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '12', + name: 'Combination Logic - Custom Event NOR (NOT) Purchase', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Not', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'chicken', + id: 13, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'birthday', + id: 16, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('12'); + }); + + it('should return null (combination logic criteria 12 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { eventName: 'birthday' }, + eventType: 'customEvent' + }, + { + items: [{ name: 'chicken', id: 'chicken', price: 10, quantity: 2 }], + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '12', + name: 'Combination Logic - Custom Event NOR (NOT) Purchase', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Not', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'chicken', + id: 13, + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'birthday', + id: 16, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); +}); diff --git a/src/anonymousUserTracking/tests/compareArrayDataTypes.test.ts b/src/anonymousUserTracking/tests/compareArrayDataTypes.test.ts new file mode 100644 index 00000000..9ee0bfc2 --- /dev/null +++ b/src/anonymousUserTracking/tests/compareArrayDataTypes.test.ts @@ -0,0 +1,804 @@ +import { SHARED_PREFS_EVENT_LIST_KEY } from '../../constants'; +import CriteriaCompletionChecker from '../criteriaCompletionChecker'; +import { + IS_NOT_ONE_OF_CRITERIA, + ARRAY_CONTAINS_CRITERIA, + ARRAY_DOES_NOT_EQUAL_CRITERIA, + ARRAY_EQUAL_CRITERIA, + ARRAY_GREATER_THAN_CRITERIA, + ARRAY_GREATER_THAN_EQUAL_TO_CRITERIA, + ARRAY_LESS_THAN_CRITERIA, + ARRAY_LESS_THAN_EQUAL_TO_CRITERIA, + ARRAY_MATCHREGEX_CRITERIA, + ARRAY_STARTSWITH_CRITERIA, + IS_ONE_OF_CRITERIA, + CUSTOM_EVENT_SINGLE_PRIMITIVE_CRITERIA +} from './constants'; + +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn() +}; + +describe('compareArrayDataTypes', () => { + beforeEach(() => { + (global as any).localStorage = localStorageMock; + }); + + // MARK: Equal + it('should return criteriaId 285 (compare array Equal)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + milestoneYears: [1996, 1997, 2002, 2020, 2024], + score: [10.5, 11.5, 12.5, 13.5, 14.5], + timestamp: [ + 1722497422151, 1722500235276, 1722500215276, 1722500225276, + 1722500245276 + ] + }, + eventType: 'user' + }, + { + eventName: 'button-clicked', + dataFields: { + animal: ['cat', 'dog', 'giraffe'] + }, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_EQUAL_CRITERIA) + ); + expect(result).toEqual('285'); + }); + + it('should return criteriaId null (compare array Equal - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + milestoneYears: [1996, 1998, 2002, 2020, 2024], + score: [12.5, 13.5, 14.5], + timestamp: [ + 1722497422151, 1722500235276, 1722500225276, 1722500245276 + ] + }, + eventType: 'user' + }, + { + eventName: 'button-clicked', + dataFields: { + animal: ['cat', 'dog'] + }, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_EQUAL_CRITERIA) + ); + expect(result).toEqual(null); + }); + + // MARK: DoesNotEqual + it('should return criteriaId 285 (compare array DoesNotEqual)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + milestoneYears: [1996, 1998, 2002, 2020, 2024], + score: [12.5, 13.5, 14.5], + timestamp: [ + 1722497422151, 1722500235276, 1722500225276, 1722500245276 + ] + }, + eventType: 'user' + }, + { + eventName: 'button-clicked', + dataFields: { + animal: ['cat', 'dog'] + }, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_DOES_NOT_EQUAL_CRITERIA) + ); + expect(result).toEqual('285'); + }); + + it('should return criteriaId null (compare array DoesNotEqual - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + milestoneYears: [1996, 1997, 2002, 2020, 2024], + score: [10.5, 11.5, 12.5, 13.5, 14.5], + timestamp: [ + 1722497422151, 1722500235276, 1722500215276, 1722500225276, + 1722500245276 + ] + }, + eventType: 'user' + }, + { + eventName: 'button-clicked', + dataFields: { + animal: ['cat', 'dog', 'giraffe'] + }, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_DOES_NOT_EQUAL_CRITERIA) + ); + expect(result).toEqual(null); + }); + + // MARK: GreaterThan + it('should return criteriaId 285 (compare array GreaterThan)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + milestoneYears: [1996, 1998, 2002, 2020, 2024] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_GREATER_THAN_CRITERIA) + ); + expect(result).toEqual('285'); + }); + + it('should return criteriaId null (compare array GreaterThan - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + milestoneYears: [1990, 1992, 1994, 1997] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_GREATER_THAN_CRITERIA) + ); + expect(result).toEqual(null); + }); + + // MARK: LessThan + it('should return criteriaId 285 (compare array LessThan)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + milestoneYears: [1990, 1992, 1994, 1996, 1998] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_LESS_THAN_CRITERIA) + ); + expect(result).toEqual('285'); + }); + + it('should return criteriaId null (compare array LessThan - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + milestoneYears: [1997, 1999, 2002, 2004] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_LESS_THAN_CRITERIA) + ); + expect(result).toEqual(null); + }); + + // MARK: GreaterThanOrEqualTo + it('should return criteriaId 285 (compare array GreaterThanOrEqualTo)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + milestoneYears: [1997, 1998, 2002, 2020, 2024] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_GREATER_THAN_EQUAL_TO_CRITERIA) + ); + expect(result).toEqual('285'); + }); + + it('should return criteriaId null (compare array GreaterThanOrEqualTo - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + milestoneYears: [1990, 1992, 1994, 1996] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_GREATER_THAN_EQUAL_TO_CRITERIA) + ); + expect(result).toEqual(null); + }); + + // MARK: LessThanOrEqualTo + it('should return criteriaId 285 (compare array LessThanOrEqualTo)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + milestoneYears: [1990, 1992, 1994, 1996, 1998] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_LESS_THAN_EQUAL_TO_CRITERIA) + ); + expect(result).toEqual('285'); + }); + + it('should return criteriaId null (compare array LessThanOrEqualTo - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + milestoneYears: [1998, 1999, 2002, 2004] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_LESS_THAN_EQUAL_TO_CRITERIA) + ); + expect(result).toEqual(null); + }); + + // MARK: Contains + it('should return criteriaId 285 (compare array Contains)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + addresses: [ + 'New York, US', + 'San Francisco, US', + 'San Diego, US', + 'Los Angeles, US', + 'Tokyo, JP', + 'Berlin, DE', + 'London, GB' + ] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_CONTAINS_CRITERIA) + ); + expect(result).toEqual('285'); + }); + + it('should return criteriaId null (compare array Contains - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + addresses: ['Tokyo, JP', 'Berlin, DE', 'London, GB'] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_CONTAINS_CRITERIA) + ); + expect(result).toEqual(null); + }); + + // MARK: StartsWith + it('should return criteriaId 285 (compare array StartsWith)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + addresses: [ + 'US, New York', + 'US, San Francisco', + 'US, San Diego', + 'US, Los Angeles', + 'JP, Tokyo', + 'DE, Berlin', + 'GB, London' + ] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_STARTSWITH_CRITERIA) + ); + expect(result).toEqual('285'); + }); + + it('should return criteriaId null (compare array StartsWith - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + addresses: ['JP, Tokyo', 'DE, Berlin', 'GB, London'] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_STARTSWITH_CRITERIA) + ); + expect(result).toEqual(null); + }); + + // MARK: MatchesRegex + it('should return criteriaId 285 (compare array MatchesRegex)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + addresses: [ + 'US, New York', + 'US, San Francisco', + 'US, San Diego', + 'US, Los Angeles', + 'JP, Tokyo', + 'DE, Berlin', + 'GB, London' + ] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_MATCHREGEX_CRITERIA) + ); + expect(result).toEqual('285'); + }); + + it('should return criteriaId null (compare array MatchesRegex - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + addresses: [ + 'US, New York', + 'US, San Francisco', + 'US, San Diego', + 'US, Los Angeles' + ] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + + const result = checker.getMatchedCriteria( + JSON.stringify(ARRAY_MATCHREGEX_CRITERIA) + ); + expect(result).toEqual(null); + }); + + // MARK: IsOneOf + it('should return criteriaId 299 (compare array IsOneOf)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + country: 'China', + addresses: ['US', 'UK', 'JP', 'DE', 'GB'] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + + const result = checker.getMatchedCriteria( + JSON.stringify(IS_ONE_OF_CRITERIA) + ); + expect(result).toEqual('299'); + }); + + it('should return criteriaId null (compare array IsOneOf - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + country: 'Korea', + addresses: ['US', 'UK'] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + + const result = checker.getMatchedCriteria( + JSON.stringify(IS_ONE_OF_CRITERIA) + ); + expect(result).toEqual(null); + }); + + // MARK: IsNotOneOf + it('should return criteriaId 299 (compare array IsNotOneOf)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + country: 'Korea', + addresses: ['US', 'UK'] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + + const result = checker.getMatchedCriteria( + JSON.stringify(IS_NOT_ONE_OF_CRITERIA) + ); + expect(result).toEqual('299'); + }); + + it('should return criteriaId null (compare array IsNotOneOf - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + country: 'China', + addresses: ['US', 'UK', 'JP', 'DE', 'GB'] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + + const result = checker.getMatchedCriteria( + JSON.stringify(IS_NOT_ONE_OF_CRITERIA) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 467 (Custom event - single primitive array)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + count: [5, 8, 9] + }, + eventType: 'customEvent', + eventName: 'animal_found' + } + ]); + } + + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + + const result = checker.getMatchedCriteria( + JSON.stringify(CUSTOM_EVENT_SINGLE_PRIMITIVE_CRITERIA) + ); + + expect(result).toEqual('467'); + }); + + it('should return criteriaId null (Custom event - single primitive array - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + count: [4, 8, 9] + }, + eventType: 'customEvent', + eventName: 'animal_found' + } + ]); + } + + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + + const result = checker.getMatchedCriteria( + JSON.stringify(CUSTOM_EVENT_SINGLE_PRIMITIVE_CRITERIA) + ); + + expect(result).toEqual(null); + }); +}); diff --git a/src/anonymousUserTracking/tests/complexCriteria.test.ts b/src/anonymousUserTracking/tests/complexCriteria.test.ts new file mode 100644 index 00000000..973262eb --- /dev/null +++ b/src/anonymousUserTracking/tests/complexCriteria.test.ts @@ -0,0 +1,1854 @@ +import { SHARED_PREFS_EVENT_LIST_KEY } from '../../constants'; +import CriteriaCompletionChecker from '../criteriaCompletionChecker'; +import { + COMPLEX_CRITERIA_1, + COMPLEX_CRITERIA_2, + COMPLEX_CRITERIA_3 +} from './constants'; + +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn() +}; + +describe('complexCriteria', () => { + beforeEach(() => { + (global as any).localStorage = localStorageMock; + }); + + // complex criteria + it('should return criteriaId 98 (complex criteria 1)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [ + { + id: '12', + name: 'monitor', + price: 50, + quantity: 10 + } + ], + total: 50, + eventType: 'purchase' + }, + { + dataFields: { + preferred_car_models: 'Honda', + country: 'Japan' + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '98', + name: 'Custom Event', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'eventName', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 23, + value: 'button.clicked' + }, + { + field: 'button-clicked.animal', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 25, + value: 'giraffe' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'updateCart.updatedShoppingCartItems.price', + fieldType: 'double', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 28, + value: '120' + }, + { + field: + 'updateCart.updatedShoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 29, + valueLong: 100, + value: '100' + } + ] + } + } + ] + }, + { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'shoppingCartItems.name', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'purchase', + id: 31, + value: 'monitor' + }, + { + field: 'shoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'purchase', + id: 32, + valueLong: 5, + value: '5' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'country', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'user', + id: 34, + value: 'Japan' + }, + { + field: 'preferred_car_models', + fieldType: 'string', + comparatorType: 'Contains', + dataType: 'user', + id: 36, + value: 'Honda' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('98'); + }); + + it('should return null (complex criteria 1 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [ + { + id: '12', + name: 'monitor', + price: 50, + quantity: 10 + } + ], + total: 50, + eventType: 'purchase' + }, + { + dataFields: { + preferred_car_models: 'Honda' + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '98', + name: 'Custom Event', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'eventName', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 23, + value: 'button.clicked' + }, + { + field: 'button-clicked.animal', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 25, + value: 'giraffe' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'updateCart.updatedShoppingCartItems.price', + fieldType: 'double', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 28, + value: '120' + }, + { + field: + 'updateCart.updatedShoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 29, + valueLong: 100, + value: '100' + } + ] + } + } + ] + }, + { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'shoppingCartItems.name', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'purchase', + id: 31, + value: 'monitor' + }, + { + field: 'shoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'purchase', + id: 32, + valueLong: 5, + value: '5' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'country', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'user', + id: 34, + value: 'Japan' + }, + { + field: 'preferred_car_models', + fieldType: 'string', + comparatorType: 'Contains', + dataType: 'user', + id: 36, + value: 'Honda' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 99 (complex criteria 2)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ id: '12', name: 'Mocha', price: 90, quantity: 50 }], + total: 50, + eventType: 'cartUpdate' + }, + { + items: [{ id: '12', name: 'Mocha', price: 90, quantity: 50 }], + total: 50, + eventType: 'cartUpdate' + }, + { + dataFields: { + preferred_car_models: 'Subaru', + country: 'USA' + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '99', + name: 'Custom Event', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'eventName', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 2, + value: 'button-clicked' + }, + { + field: 'button-clicked.lastPageViewed', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 4, + value: 'welcome page' + } + ] + } + }, + { + dataType: 'customEvent', + minMatch: 2, + maxMatch: 3, + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'updateCart.updatedShoppingCartItems.price', + fieldType: 'double', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 6, + value: '85' + }, + { + field: + 'updateCart.updatedShoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 7, + valueLong: 50, + value: '50' + } + ] + } + } + ] + }, + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'shoppingCartItems.name', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'purchase', + id: 16, + isFiltering: false, + value: 'coffee' + }, + { + field: 'shoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'purchase', + id: 17, + valueLong: 2, + value: '2' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'country', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'user', + id: 19, + value: 'USA' + }, + { + field: 'preferred_car_models', + fieldType: 'string', + comparatorType: 'Contains', + dataType: 'user', + id: 21, + value: 'Subaru' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('99'); + }); + + it('should return null (complex criteria 2 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ id: '12', name: 'Mocha', price: 90, quantity: 50 }], + total: 50, + eventType: 'cartUpdate' + }, + { + dataFields: { + preferred_car_models: 'Subaru', + country: 'USA' + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '99', + name: 'Custom Event', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'eventName', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 2, + value: 'button-clicked' + }, + { + field: 'button-clicked.lastPageViewed', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 4, + value: 'welcome page' + } + ] + } + }, + { + dataType: 'customEvent', + minMatch: 2, + maxMatch: 3, + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'updateCart.updatedShoppingCartItems.price', + fieldType: 'double', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 6, + value: '85' + }, + { + field: + 'updateCart.updatedShoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 7, + valueLong: 50, + value: '50' + } + ] + } + } + ] + }, + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'shoppingCartItems.name', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'purchase', + id: 16, + isFiltering: false, + value: 'coffee' + }, + { + field: 'shoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'purchase', + id: 17, + valueLong: 2, + value: '2' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'country', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'user', + id: 19, + value: 'USA' + }, + { + field: 'preferred_car_models', + fieldType: 'string', + comparatorType: 'Contains', + dataType: 'user', + id: 21, + value: 'Subaru' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 100 (complex criteria 3)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ id: '12', name: 'Mocha', price: 90, quantity: 50 }], + total: 50, + eventType: 'cartUpdate' + }, + { + items: [{ id: '12', name: 'Mocha', price: 90, quantity: 50 }], + total: 50, + eventType: 'cartUpdate' + }, + + { + dataFields: { + preferred_car_models: 'Subaru', + country: 'USA' + }, + eventType: 'user' + }, + { + eventName: 'button-clicked', + dataFields: { + lastPageViewed: 'welcome page' + }, + eventType: 'customEvent' + }, + { + items: [ + { + id: '12', + name: 'coffee', + price: 10, + quantity: 5 + } + ], + total: 2, + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '100', + name: 'Custom Event', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'eventName', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 2, + value: 'button-clicked' + }, + { + field: 'button-clicked.lastPageViewed', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 4, + value: 'welcome page' + } + ] + } + }, + { + dataType: 'customEvent', + minMatch: 2, + maxMatch: 3, + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'updateCart.updatedShoppingCartItems.price', + fieldType: 'double', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 6, + value: '85' + }, + { + field: + 'updateCart.updatedShoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 7, + valueLong: 50, + value: '50' + } + ] + } + }, + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'shoppingCartItems.name', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'purchase', + id: 9, + value: 'coffee' + }, + { + field: 'shoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'purchase', + id: 10, + valueLong: 2, + value: '2' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'country', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'user', + id: 12, + value: 'USA' + }, + { + field: 'preferred_car_models', + fieldType: 'string', + comparatorType: 'Contains', + dataType: 'user', + id: 14, + value: 'Subaru' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('100'); + }); + + it('should return null (complex criteria 3 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ id: '12', name: 'Mocha', price: 90, quantity: 50 }], + total: 50, + eventType: 'cartUpdate' + }, + { + items: [{ id: '12', name: 'Mocha', price: 90, quantity: 50 }], + total: 50, + eventType: 'cartUpdate' + }, + + { + dataFields: { + preferred_car_models: 'Subaru', + country: 'USA' + }, + eventType: 'user' + }, + { + eventName: 'button-clicked', + dataFields: { + 'button-clicked.lastPageViewed': 'welcome page' + }, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '100', + name: 'Custom Event', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'eventName', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 2, + value: 'button-clicked' + }, + { + field: 'button-clicked.lastPageViewed', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 4, + value: 'welcome page' + } + ] + } + }, + { + dataType: 'customEvent', + minMatch: 2, + maxMatch: 3, + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'updateCart.updatedShoppingCartItems.price', + fieldType: 'double', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 6, + value: '85' + }, + { + field: + 'updateCart.updatedShoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'customEvent', + id: 7, + valueLong: 50, + value: '50' + } + ] + } + }, + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'shoppingCartItems.name', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'purchase', + id: 9, + value: 'coffee' + }, + { + field: 'shoppingCartItems.quantity', + fieldType: 'long', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'purchase', + id: 10, + valueLong: 2, + value: '2' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'country', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'user', + id: 12, + value: 'USA' + }, + { + field: 'preferred_car_models', + fieldType: 'string', + comparatorType: 'Contains', + dataType: 'user', + id: 14, + value: 'Subaru' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 101 (complex criteria 4)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [ + { id: '12', name: 'sneakers', price: 10, quantity: 5 }, + { id: '13', name: 'slippers', price: 10, quantity: 3 } + ], + total: 2, + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '101', + name: 'Complex Criteria 4: (NOT 9) AND 10', + createdAt: 1719328083918, + updatedAt: 1719328083918, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Not', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'sneakers', + fieldType: 'string' + }, + { + dataType: 'purchase', + field: 'shoppingCartItems.quantity', + comparatorType: 'LessThanOrEqualTo', + value: '3', + fieldType: 'long' + } + ] + } + } + ] + }, + { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'slippers', + fieldType: 'string' + }, + { + dataType: 'purchase', + field: 'shoppingCartItems.quantity', + comparatorType: 'GreaterThanOrEqualTo', + value: '3', + fieldType: 'long' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('101'); + }); + + it('should return null (complex criteria 4 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [ + { id: '12', name: 'sneakers', price: 10, quantity: 2 }, + { id: '13', name: 'slippers', price: 10, quantity: 3 } + ], + total: 2, + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '101', + name: 'Complex Criteria 4: (NOT 9) AND 10', + createdAt: 1719328083918, + updatedAt: 1719328083918, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Not', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'sneakers', + fieldType: 'string' + }, + { + dataType: 'purchase', + field: 'shoppingCartItems.quantity', + comparatorType: 'LessThanOrEqualTo', + value: '3', + fieldType: 'long' + } + ] + } + } + ] + }, + { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'slippers', + fieldType: 'string' + }, + { + dataType: 'purchase', + field: 'shoppingCartItems.quantity', + comparatorType: 'GreaterThanOrEqualTo', + value: '3', + fieldType: 'long' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 134 (Min-Max 2)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ id: '12', name: 'Mocha', price: 50, quantity: 50 }], + eventType: 'cartUpdate' + }, + { + items: [{ id: '12', name: 'Mocha', price: 50.0, quantity: 50 }], + eventType: 'cartUpdate' + }, + { + dataFields: { + preferred_car_models: 'Honda' + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '134', + name: 'Min-Max 2', + createdAt: 1719336370734, + updatedAt: 1719337067199, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.price', + comparatorType: 'Equals', + value: '50.0', + fieldType: 'double' + } + ] + }, + minMatch: 2, + maxMatch: 3 + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'preferred_car_models', + comparatorType: 'Equals', + value: 'Honda', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('134'); + }); + + it('should return criteriaId 151 (sampleTest1)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + 'animal-found': { + type: 'cat', + count: 4 + } + }, + eventType: 'customEvent' + }, + { + items: [{ id: '12', name: 'Caramel', price: 3, quantity: 5 }], + total: 2, + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '151', + name: 'test criteria', + createdAt: 1719336370734, + updatedAt: 1719337067199, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'animal-found.type', + comparatorType: 'Equals', + value: 'cat', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'animal-found.count', + comparatorType: 'LessThan', + value: '5', + fieldType: 'long' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.price', + comparatorType: 'GreaterThanOrEqualTo', + value: '500', + fieldType: 'double' + }, + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.price', + comparatorType: 'LessThan', + value: '20', + fieldType: 'double' + } + ] + } + } + ] + }, + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'Caramel', + fieldType: 'string' + }, + { + dataType: 'purchase', + field: 'shoppingCartItems.quantity', + comparatorType: 'GreaterThanOrEqualTo', + value: '2', + fieldType: 'long' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'country', + comparatorType: 'Equals', + value: 'UK', + fieldType: 'string' + }, + { + dataType: 'user', + field: 'likes_boba', + comparatorType: 'Equals', + value: 'false', + fieldType: 'boolean' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('151'); + }); + + it('should return null (sampleTest1 fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + 'animal-found.type': 'dog', + 'animal-found.count': 4 + }, + eventType: 'customEvent' + }, + { + items: [{ id: '12', name: 'Caramel', price: 3, quantity: 5 }], + total: 2, + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '151', + name: 'test criteria', + createdAt: 1719336370734, + updatedAt: 1719337067199, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'animal-found.type', + comparatorType: 'Equals', + value: 'cat', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'animal-found.count', + comparatorType: 'LessThan', + value: '5', + fieldType: 'long' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.price', + comparatorType: 'GreaterThanOrEqualTo', + value: '500', + fieldType: 'double' + }, + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.price', + comparatorType: 'LessThan', + value: '20', + fieldType: 'double' + } + ] + } + } + ] + }, + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'Caramel', + fieldType: 'string' + }, + { + dataType: 'purchase', + field: 'shoppingCartItems.quantity', + comparatorType: 'GreaterThanOrEqualTo', + value: '2', + fieldType: 'long' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'country', + comparatorType: 'Equals', + value: 'UK', + fieldType: 'string' + }, + { + dataType: 'user', + field: 'likes_boba', + comparatorType: 'Equals', + value: 'false', + fieldType: 'boolean' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + // MARK: Complex criteria #1 + it('should return criteriaId 290 if (1 OR 2 OR 3) AND (4 AND 5) AND (6 NOT 7) matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + saved_cars: { color: 'black' }, + 'animal-found': { vaccinated: true }, + eventName: 'birthday' + }, + eventType: 'customEvent' + }, + { + dataFields: { reason: 'testing', total: 30 }, + eventType: 'purchase' + }, + { + dataFields: { firstName: 'Adam' }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(COMPLEX_CRITERIA_1) + ); + expect(result).toEqual('290'); + }); + + it('should return criteriaId null if (1 OR 2 OR 3) AND (4 AND 5) AND (6 NOT 7) - No match', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + eventType: 'user', + dataFields: { + firstName: 'Alex' + } + }, + { + eventType: 'customEvent', + eventName: 'saved_cars', + dataFields: { + color: '' + } + }, + { + eventType: 'customEvent', + eventName: 'animal-found', + dataFields: { + vaccinated: true + } + }, + { + eventType: 'purchase', + dataFields: { + total: 30, + reason: 'testing' + } + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(COMPLEX_CRITERIA_1) + ); + expect(result).toEqual(null); + }); + + // MARK: Complex criteria #2 + it('should return criteriaId 291 if (6 OR 7) OR (4 AND 5) OR (1 NOT 2 NOT 3) matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + eventType: 'user', + dataFields: { + firstName: 'xcode' + } + }, + { + eventType: 'customEvent', + eventName: 'saved_cars', + dataFields: { + color: 'black' + } + }, + { + eventType: 'customEvent', + eventName: 'animal-found', + dataFields: { + vaccinated: true + } + }, + { + eventType: 'purchase', + dataFields: { + total: 110, + reason: 'testing' + } + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(COMPLEX_CRITERIA_2) + ); + expect(result).toEqual('291'); + }); + + it('should return criteriaId null if (6 OR 7) OR (4 AND 5) OR (1 NOT 2 NOT 3) - No match', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + eventType: 'user', + dataFields: { + firstName: 'Alex' + } + }, + { + eventType: 'purchase', + dataFields: { + total: 10, + reason: 'null' + } + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(COMPLEX_CRITERIA_2) + ); + expect(result).toEqual(null); + }); + + // MARK: Complex criteria #3 + it('should return criteriaId 292 if (1 AND 2) NOR (3 OR 4 OR 5) NOR (6 NOR 7) matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataType: 'user', + dataFields: { + firstName: 'xcode', + lastName: 'ssr' + } + }, + { + dataType: 'customEvent', + eventName: 'animal-found', + dataFields: { + vaccinated: true, + count: 10 + } + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(COMPLEX_CRITERIA_3) + ); + expect(result).toEqual('292'); + }); + + it('should return criteriaId null if (1 AND 2) NOR (3 OR 4 OR 5) NOR (6 NOR 7) - No match', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + eventType: 'user', + dataFields: { + firstName: 'Alex', + lastName: 'Aris' + } + }, + { + eventType: 'customEvent', + eventName: 'animal-found', + dataFields: { + vaccinated: false, + count: 4 + } + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(COMPLEX_CRITERIA_3) + ); + expect(result).toEqual(null); + }); +}); diff --git a/src/anonymousUserTracking/tests/constants.ts b/src/anonymousUserTracking/tests/constants.ts new file mode 100644 index 00000000..6773652a --- /dev/null +++ b/src/anonymousUserTracking/tests/constants.ts @@ -0,0 +1,1682 @@ +// CRITERIA TEST CONSTANTS + +export const DATA_TYPE_COMPARATOR_EQUALS = { + count: 1, + criteriaSets: [ + { + criteriaId: '285', + name: 'Criteria_EventTimeStamp_3_Long', + createdAt: 1722497422151, + updatedAt: 1722500235276, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'eventTimeStamp', + comparatorType: 'Equals', + value: '3', + fieldType: 'long' + }, + { + dataType: 'user', + field: 'savings', + comparatorType: 'Equals', + value: '19.99', + fieldType: 'double' + }, + { + dataType: 'user', + field: 'likes_boba', + comparatorType: 'Equals', + value: 'true', + fieldType: 'boolean' + }, + { + dataType: 'user', + field: 'country', + comparatorType: 'Equals', + value: 'Chaina', + fieldType: 'String' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const DATA_TYPE_COMPARATOR_DOES_NOT_EQUAL = { + count: 1, + criteriaSets: [ + { + criteriaId: '285', + name: 'Criteria_EventTimeStamp_3_Long', + createdAt: 1722497422151, + updatedAt: 1722500235276, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'eventTimeStamp', + comparatorType: 'DoesNotEqual', + value: '3', + fieldType: 'long' + }, + { + dataType: 'user', + field: 'savings', + comparatorType: 'DoesNotEqual', + value: '19.99', + fieldType: 'double' + }, + { + dataType: 'user', + field: 'likes_boba', + comparatorType: 'DoesNotEqual', + value: 'true', + fieldType: 'boolean' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const DATA_TYPE_COMPARATOR_LESS_THAN = { + count: 1, + criteriaSets: [ + { + criteriaId: '289', + name: 'Criteria_EventTimeStamp_3_Long', + createdAt: 1722497422151, + updatedAt: 1722500235276, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'eventTimeStamp', + comparatorType: 'LessThan', + value: '15', + fieldType: 'long' + }, + { + dataType: 'user', + field: 'savings', + comparatorType: 'LessThan', + value: '15', + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const DATA_TYPE_COMPARATOR_LESS_THAN_OR_EQUAL_TO = { + count: 1, + criteriaSets: [ + { + criteriaId: '290', + name: 'Criteria_EventTimeStamp_3_Long', + createdAt: 1722497422151, + updatedAt: 1722500235276, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'eventTimeStamp', + comparatorType: 'LessThanOrEqualTo', + value: '17', + fieldType: 'long' + }, + { + dataType: 'user', + field: 'savings', + comparatorType: 'LessThanOrEqualTo', + value: '17', + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const DATA_TYPE_COMPARATOR_GREATER_THAN = { + count: 1, + criteriaSets: [ + { + criteriaId: '290', + name: 'Criteria_EventTimeStamp_3_Long', + createdAt: 1722497422151, + updatedAt: 1722500235276, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'eventTimeStamp', + comparatorType: 'GreaterThan', + value: '50', + fieldType: 'long' + }, + { + dataType: 'user', + field: 'savings', + comparatorType: 'GreaterThan', + value: '55', + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const DATA_TYPE_COMPARATOR_GREATER_THAN_OR_EQUAL_TO = { + count: 1, + criteriaSets: [ + { + criteriaId: '291', + name: 'Criteria_EventTimeStamp_3_Long', + createdAt: 1722497422151, + updatedAt: 1722500235276, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'eventTimeStamp', + comparatorType: 'GreaterThanOrEqualTo', + value: '20', + fieldType: 'long' + }, + { + dataType: 'user', + field: 'savings', + comparatorType: 'GreaterThanOrEqualTo', + value: '20', + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const DATA_TYPE_COMPARATOR_IS_SET = { + count: 1, + criteriaSets: [ + { + criteriaId: '285', + name: 'Criteria_EventTimeStamp_3_Long', + createdAt: 1722497422151, + updatedAt: 1722500235276, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'eventTimeStamp', + comparatorType: 'IsSet', + value: '', + fieldType: 'long' + }, + { + dataType: 'user', + field: 'savings', + comparatorType: 'IsSet', + value: '', + fieldType: 'double' + }, + { + dataType: 'user', + field: 'saved_cars', + comparatorType: 'IsSet', + value: '', + fieldType: 'double' + }, + { + dataType: 'user', + field: 'country', + comparatorType: 'IsSet', + value: '', + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const ARRAY_EQUAL_CRITERIA = { + count: 1, + criteriaSets: [ + { + criteriaId: '285', + name: 'Criteria_Array_Equal', + createdAt: 1722497422151, + updatedAt: 1722500235276, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'milestoneYears', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'user', + id: 2, + value: '1997' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'score', + fieldType: 'double', + comparatorType: 'Equals', + dataType: 'user', + id: 2, + value: '11.5' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'timestamp', + fieldType: 'long', + comparatorType: 'Equals', + dataType: 'user', + id: 2, + valueLong: 1722500215276, + value: '1722500215276' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'button-clicked.animal', + fieldType: 'string', + comparatorType: 'Equals', + dataType: 'customEvent', + id: 25, + value: 'giraffe' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const ARRAY_DOES_NOT_EQUAL_CRITERIA = { + count: 1, + criteriaSets: [ + { + criteriaId: '285', + name: 'Criteria_Array_DoesNotEqual', + createdAt: 1722497422151, + updatedAt: 1722500235276, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'milestoneYears', + fieldType: 'string', + comparatorType: 'DoesNotEqual', + dataType: 'user', + id: 2, + value: '1997' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'score', + fieldType: 'double', + comparatorType: 'DoesNotEqual', + dataType: 'user', + id: 2, + value: '11.5' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'timestamp', + fieldType: 'long', + comparatorType: 'DoesNotEqual', + dataType: 'user', + id: 2, + valueLong: 1722500215276, + value: '1722500215276' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'button-clicked.animal', + fieldType: 'string', + comparatorType: 'DoesNotEqual', + dataType: 'customEvent', + id: 25, + value: 'giraffe' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const ARRAY_GREATER_THAN_CRITERIA = { + count: 1, + criteriaSets: [ + { + criteriaId: '285', + name: 'Criteria_EventTimeStamp_3_Long', + createdAt: 1722497422151, + updatedAt: 1722500235276, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'milestoneYears', + fieldType: 'string', + comparatorType: 'GreaterThan', + dataType: 'user', + id: 2, + value: '1997' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const ARRAY_LESS_THAN_CRITERIA = { + count: 1, + criteriaSets: [ + { + criteriaId: '285', + name: 'Criteria_EventTimeStamp_3_Long', + createdAt: 1722497422151, + updatedAt: 1722500235276, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'milestoneYears', + fieldType: 'string', + comparatorType: 'LessThan', + dataType: 'user', + id: 2, + value: '1997' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const ARRAY_GREATER_THAN_EQUAL_TO_CRITERIA = { + count: 1, + criteriaSets: [ + { + criteriaId: '285', + name: 'Criteria_EventTimeStamp_3_Long', + createdAt: 1722497422151, + updatedAt: 1722500235276, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'milestoneYears', + fieldType: 'string', + comparatorType: 'GreaterThanOrEqualTo', + dataType: 'user', + id: 2, + value: '1997' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const ARRAY_LESS_THAN_EQUAL_TO_CRITERIA = { + count: 1, + criteriaSets: [ + { + criteriaId: '285', + name: 'Criteria_EventTimeStamp_3_Long', + createdAt: 1722497422151, + updatedAt: 1722500235276, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'milestoneYears', + fieldType: 'string', + comparatorType: 'LessThanOrEqualTo', + dataType: 'user', + id: 2, + value: '1997' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const ARRAY_CONTAINS_CRITERIA = { + count: 1, + criteriaSets: [ + { + criteriaId: '285', + name: 'Criteria_EventTimeStamp_3_Long', + createdAt: 1722497422151, + updatedAt: 1722500235276, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'addresses', + fieldType: 'string', + comparatorType: 'Contains', + dataType: 'user', + id: 2, + value: 'US' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const ARRAY_STARTSWITH_CRITERIA = { + count: 1, + criteriaSets: [ + { + criteriaId: '285', + name: 'Criteria_EventTimeStamp_3_Long', + createdAt: 1722497422151, + updatedAt: 1722500235276, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'addresses', + fieldType: 'string', + comparatorType: 'StartsWith', + dataType: 'user', + id: 2, + value: 'US' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const ARRAY_MATCHREGEX_CRITERIA = { + count: 1, + criteriaSets: [ + { + criteriaId: '285', + name: 'Criteria_EventTimeStamp_3_Long', + createdAt: 1722497422151, + updatedAt: 1722500235276, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'addresses', + fieldType: 'string', + comparatorType: 'MatchesRegex', + dataType: 'user', + id: 2, + value: '^(JP|DE|GB)' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const NESTED_CRITERIA = { + count: 1, + criteriaSets: [ + { + criteriaId: '168', + name: 'nested testing', + createdAt: 1721251169153, + updatedAt: 1723488175352, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'furniture', + comparatorType: 'IsSet', + value: '', + fieldType: 'nested' + }, + { + dataType: 'user', + field: 'furniture.furnitureType', + comparatorType: 'Equals', + value: 'Sofa', + fieldType: 'string' + }, + { + dataType: 'user', + field: 'furniture.furnitureColor', + comparatorType: 'Equals', + value: 'White', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const NESTED_CRITERIA_MULTI_LEVEL = { + count: 1, + criteriaSets: [ + { + criteriaId: '425', + name: 'Multi level Nested field criteria', + createdAt: 1721251169153, + updatedAt: 1723488175352, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'button-clicked.browserVisit.website.domain', + comparatorType: 'Equals', + value: 'https://mybrand.com/socks', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const NESTED_CRITERIA_MULTI_LEVEL_ARRAY = { + count: 1, + criteriaSets: [ + { + criteriaId: '436', + name: 'Criteria 2.1 - 09252024 Bug Bash', + createdAt: 1727286807360, + updatedAt: 1727445082036, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'furniture.material.type', + comparatorType: 'Contains', + value: 'table', + fieldType: 'string' + }, + { + dataType: 'user', + field: 'furniture.material.color', + comparatorType: 'Equals', + values: ['black'] + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const NESTED_CRITERIA_MULTI_LEVEL_ARRAY_TRACK_EVENT = { + count: 1, + criteriaSets: [ + { + criteriaId: '459', + name: 'event a.h.b=d && a.h.c=g', + createdAt: 1721251169153, + updatedAt: 1723488175352, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'TopLevelArrayObject.a.h.b', + comparatorType: 'Equals', + value: 'd', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'TopLevelArrayObject.a.h.c', + comparatorType: 'Equals', + value: 'g', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const IS_ONE_OF_CRITERIA = { + count: 1, + criteriaSets: [ + { + criteriaId: '299', + name: 'Criteria_Is_One_of', + createdAt: 1722851586508, + updatedAt: 1724404229481, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'country', + comparatorType: 'Equals', + values: ['China', 'Japan', 'Kenya'] + }, + { + dataType: 'user', + field: 'addresses', + comparatorType: 'Equals', + values: ['JP', 'DE', 'GB'] + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const IS_NOT_ONE_OF_CRITERIA = { + count: 1, + criteriaSets: [ + { + criteriaId: '299', + name: 'Criteria_Is_Not_One_of', + createdAt: 1722851586508, + updatedAt: 1724404229481, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'country', + comparatorType: 'DoesNotEqual', + values: ['China', 'Japan', 'Kenya'] + }, + { + dataType: 'user', + field: 'addresses', + comparatorType: 'DoesNotEqual', + values: ['JP', 'DE', 'GB'] + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const CUSTOM_EVENT_SINGLE_PRIMITIVE_CRITERIA = { + count: 1, + criteriaSets: [ + { + criteriaId: '467', + name: 'Custom event - single primitive', + createdAt: 1728166585122, + updatedAt: 1729581351423, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'animal_found', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'animal_found.count', + comparatorType: 'DoesNotEqual', + value: '4', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const CUSTOM_EVENT_API_TEST_CRITERIA = { + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'EventCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'animal-found', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'animal-found.type', + comparatorType: 'Equals', + value: 'cat', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'animal-found.count', + comparatorType: 'Equals', + value: '6', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'animal-found.vaccinated', + comparatorType: 'Equals', + value: 'true', + fieldType: 'boolean' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const USER_UPDATE_API_TEST_CRITERIA = { + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'UserCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'furniture.furnitureType', + comparatorType: 'Equals', + value: 'Sofa', + fieldType: 'string' + }, + { + dataType: 'user', + field: 'furniture.furnitureColor', + comparatorType: 'Equals', + value: 'White', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const USER_MERGE_SCENARIO_CRITERIA = { + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'EventCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'testEvent', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const NESTED_CRITERIA_MULTI_LEVEL_MORE_THAN_4_EVENTS = { + count: 1, + criteriaSets: [ + { + criteriaId: '484', + name: 'NickBBFinalUserFlow', + createdAt: 1729009617581, + updatedAt: 1730096250121, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'likes_boba', + comparatorType: 'Equals', + value: 'true', + fieldType: 'boolean' + } + ] + } + } + ] + }, + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'cancelled_booking.details.event.name', + comparatorType: 'Equals', + value: 'haircut', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +// MARK:Complex Criteria + +export const COMPLEX_CRITERIA_1 = { + count: 1, + criteriaSets: [ + { + criteriaId: '290', + name: 'Complex Criteria Unit Test #1', + createdAt: 1722532861551, + updatedAt: 1722532861551, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'StartsWith', + value: 'A', + fieldType: 'string' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'StartsWith', + value: 'B', + fieldType: 'string' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'StartsWith', + value: 'C', + fieldType: 'string' + } + ] + } + } + ] + }, + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'IsSet', + value: '', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'saved_cars.color', + comparatorType: 'IsSet', + value: '', + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'IsSet', + value: '', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'animal-found.vaccinated', + comparatorType: 'Equals', + value: 'true', + fieldType: 'boolean' + } + ] + } + } + ] + }, + { + combinator: 'Not', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'total', + comparatorType: 'LessThanOrEqualTo', + value: '100', + fieldType: 'double' + } + ] + } + }, + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'reason', + comparatorType: 'Equals', + value: 'null', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const COMPLEX_CRITERIA_2 = { + count: 1, + criteriaSets: [ + { + criteriaId: '291', + name: 'Complex Criteria Unit Test #2', + createdAt: 1722533473263, + updatedAt: 1722533473263, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Not', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'StartsWith', + value: 'A', + fieldType: 'string' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'StartsWith', + value: 'B', + fieldType: 'string' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'StartsWith', + value: 'C', + fieldType: 'string' + } + ] + } + } + ] + }, + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'IsSet', + value: '', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'saved_cars.color', + comparatorType: 'IsSet', + value: '', + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'animal-found.vaccinated', + comparatorType: 'Equals', + value: 'true', + fieldType: 'boolean' + } + ] + } + } + ] + }, + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'total', + comparatorType: 'GreaterThanOrEqualTo', + value: '100', + fieldType: 'double' + } + ] + } + }, + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'reason', + comparatorType: 'DoesNotEqual', + value: 'null', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] +}; + +export const COMPLEX_CRITERIA_3 = { + count: 1, + criteriaSets: [ + { + criteriaId: '292', + name: 'Complex Criteria Unit Test #3', + createdAt: 1722533789589, + updatedAt: 1722533838989, + searchQuery: { + combinator: 'Not', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'StartsWith', + value: 'A', + fieldType: 'string' + } + ] + } + }, + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'lastName', + comparatorType: 'StartsWith', + value: 'A', + fieldType: 'string' + } + ] + } + } + ] + }, + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'firstName', + comparatorType: 'StartsWith', + value: 'C', + fieldType: 'string' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'animal-found.vaccinated', + comparatorType: 'Equals', + value: 'false', + fieldType: 'boolean' + } + ] + } + }, + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'animal-found.count', + comparatorType: 'LessThan', + value: '5', + fieldType: 'long' + } + ] + } + } + ] + } + ] + } + } + ] +}; diff --git a/src/anonymousUserTracking/tests/criteriaCompletionChecker.test.ts b/src/anonymousUserTracking/tests/criteriaCompletionChecker.test.ts new file mode 100644 index 00000000..931a188c --- /dev/null +++ b/src/anonymousUserTracking/tests/criteriaCompletionChecker.test.ts @@ -0,0 +1,1792 @@ +import { SHARED_PREFS_EVENT_LIST_KEY } from '../../constants'; +import CriteriaCompletionChecker from '../criteriaCompletionChecker'; + +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn() +}; + +describe('CriteriaCompletionChecker', () => { + beforeEach(() => { + (global as any).localStorage = localStorageMock; + }); + + it('should return null if criteriaData is empty', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return '[]'; + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria('{}'); + expect(result).toBeNull(); + }); + + it('should return criteriaId if customEvent is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + eventName: 'testEvent', + createdAt: 1708494757530, + dataFields: { + browserVisit: { + website: { + domain: 'google.com' + } + } + }, + createNewFields: true, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'EventCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'testEvent', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'browserVisit.website.domain', + comparatorType: 'Equals', + value: 'google.com', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('6'); + }); + + it('should return criteriaId if customEvent is matched when minMatch present', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + eventName: 'testEvent', + createdAt: 1708494757530, + dataFields: { browserVisit: { website: { domain: 'google.com' } } }, + createNewFields: true, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'EventCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + minMatch: 1, + maxMatch: 2, + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'testEvent', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'browserVisit.website.domain', + comparatorType: 'Equals', + value: 'google.com', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('6'); + }); + + it('should return criteriaId if purchase event is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + createdAt: 1708494757530, + items: [ + { name: 'keyboard', id: 'fdsafds', price: 10, quantity: 2 } + ], + total: 10, + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'EventCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'keyboard', + fieldType: 'string' + }, + { + dataType: 'purchase', + field: 'shoppingCartItems.price', + comparatorType: 'GreaterThanOrEqualTo', + value: '10', + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('6'); + }); + + it('should return null if updateCart event with all props in item is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + createdAt: 1708494757530, + items: [ + { name: 'keyboard', id: 'fdsafds', price: 10, quantity: 2 }, + { name: 'Cofee', id: 'fdsafds', price: 10, quantity: 2 } + ], + eventType: 'cartUpdate' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'EventCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'updateCart', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.name', + comparatorType: 'Equals', + value: 'keyboard', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.price', + comparatorType: 'GreaterThanOrEqualTo', + value: '10', + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('6'); + }); + + it('should return null if updateCart event with items is NOT matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + createdAt: 1708494757530, + items: [ + { name: 'keyboard', id: 'fdsafds', price: 9, quantity: 2 }, + { name: 'Cofee', id: 'fdsafds', price: 10, quantity: 2 } + ], + eventType: 'cartUpdate' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'EventCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'updateCart', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.name', + comparatorType: 'Equals', + value: 'keyboard', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.price', + comparatorType: 'GreaterThanOrEqualTo', + value: '10', + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toBeNull(); + }); + + it('should return criteriaId if updateCart event is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + createdAt: 1708494757530, + items: [ + { name: 'keyboard', id: 'fdsafds', price: 10, quantity: 2 } + ], + eventType: 'cartUpdate' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'EventCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Equals', + value: 'updateCart', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.price', + comparatorType: 'GreaterThanOrEqualTo', + value: '10', + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('6'); + }); + + it('should return criteriaId if criteriaData condition with numeric is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [ + { + id: '123', + name: 'Black Coffee', + quantity: 1, + price: 4 + } + ], + user: { + userId: 'user' + }, + total: 0, + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'shoppingCartItemsCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.name', + comparatorType: 'Equals', + value: 'Black Coffee', + fieldType: 'string' + }, + { + dataType: 'purchase', + field: 'shoppingCartItems.price', + comparatorType: 'GreaterThanOrEqualTo', + value: '4.00', + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('6'); + }); + + it('should return criteriaId if criteriaData condition with StartsWith is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + eventName: 'testEvent', + createdAt: 1708494757530, + dataFields: undefined, + createNewFields: true, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'EventCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'StartsWith', + value: 'test', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('6'); + + const result1 = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'EventCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'eventName', + comparatorType: 'Contains', + value: 'test', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result1).toEqual('6'); + }); + + it('should return criteriaId if criteria regex match with value is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + email: 'testEvent@example.com', + createdAt: 1708494757530, + dataFields: undefined, + createNewFields: true, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '6', + name: 'EventCriteria', + createdAt: 1704754280210, + updatedAt: 1704754280210, + searchQuery: { + combinator: 'Or', + searchQueries: [ + { + combinator: 'Or', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'email', + comparatorType: 'MatchesRegex', + value: /^[a-zA-Z0-9]+@(?:[a-zA-Z0-9]+.)+[A-Za-z]+$/, + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('6'); + }); + + // isSet criteria + it('should return criteriaId 97 if isset user criteria is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + country: 'UK', + eventTimeStamp: 10, + phoneNumberDetails: '99999999', + 'shoppingCartItems.price': 50.5 + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '97', + name: 'User', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'country', + fieldType: 'string', + comparatorType: 'IsSet', + dataType: 'user', + id: 25, + value: '' + }, + { + field: 'eventTimeStamp', + fieldType: 'long', + comparatorType: 'IsSet', + dataType: 'user', + id: 26, + valueLong: null, + value: '' + }, + { + field: 'phoneNumberDetails', + fieldType: 'object', + comparatorType: 'IsSet', + dataType: 'user', + id: 28, + value: '' + }, + { + field: 'shoppingCartItems.price', + fieldType: 'double', + comparatorType: 'IsSet', + dataType: 'user', + id: 30, + value: '' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('97'); + }); + + it('should return null (isset user criteria fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + eventTimeStamp: 10, + phoneNumberDetails: '99999999', + 'shoppingCartItems.price': 50.5 + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '97', + name: 'User', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'country', + fieldType: 'string', + comparatorType: 'IsSet', + dataType: 'user', + id: 25, + value: '' + }, + { + field: 'eventTimeStamp', + fieldType: 'long', + comparatorType: 'IsSet', + dataType: 'user', + id: 26, + valueLong: null, + value: '' + }, + { + field: 'phoneNumberDetails', + fieldType: 'object', + comparatorType: 'IsSet', + dataType: 'user', + id: 28, + value: '' + }, + { + field: 'shoppingCartItems.price', + fieldType: 'double', + comparatorType: 'IsSet', + dataType: 'user', + id: 30, + value: '' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 94 if isset customEvent criteria is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + eventName: 'button-clicked', + dataFields: { + animal: 'test page', + clickCount: '2', + total: 3 + }, + createdAt: 1700071052507, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '94', + name: 'Custom Event', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'button-clicked', + fieldType: 'object', + comparatorType: 'IsSet', + dataType: 'customEvent', + id: 2, + value: '' + }, + { + field: 'button-clicked.animal', + fieldType: 'string', + comparatorType: 'IsSet', + dataType: 'customEvent', + id: 4, + value: '' + }, + { + field: 'button-clicked.clickCount', + fieldType: 'long', + comparatorType: 'IsSet', + dataType: 'customEvent', + id: 5, + valueLong: null, + value: '' + }, + { + field: 'total', + fieldType: 'double', + comparatorType: 'IsSet', + dataType: 'customEvent', + id: 9, + value: '' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('94'); + }); + + it('should return null (isset customEvent criteria fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + eventName: 'button-clicked', + dataFields: { + 'button-clicked': { animal: 'test page' }, + total: 3 + }, + createdAt: 1700071052507, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '94', + name: 'Custom Event', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'button-clicked', + fieldType: 'object', + comparatorType: 'IsSet', + dataType: 'customEvent', + id: 2, + value: '' + }, + { + field: 'button-clicked.animal', + fieldType: 'string', + comparatorType: 'IsSet', + dataType: 'customEvent', + id: 4, + value: '' + }, + { + field: 'button-clicked.clickCount', + fieldType: 'long', + comparatorType: 'IsSet', + dataType: 'customEvent', + id: 5, + valueLong: null, + value: '' + }, + { + field: 'total', + fieldType: 'double', + comparatorType: 'IsSet', + dataType: 'customEvent', + id: 9, + value: '' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 96 if isset purchase criteria is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [ + { + id: '12', + name: 'monitor', + price: 10, + quantity: 10 + } + ], + total: 50, + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '96', + name: 'Purchase', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'shoppingCartItems', + fieldType: 'object', + comparatorType: 'IsSet', + dataType: 'purchase', + id: 1, + value: '' + }, + { + field: 'shoppingCartItems.price', + fieldType: 'double', + comparatorType: 'IsSet', + dataType: 'purchase', + id: 3, + value: '' + }, + { + field: 'shoppingCartItems.name', + fieldType: 'string', + comparatorType: 'IsSet', + dataType: 'purchase', + id: 5, + value: '' + }, + { + field: 'total', + fieldType: 'double', + comparatorType: 'IsSet', + dataType: 'purchase', + id: 7, + value: '' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('96'); + }); + + it('should return null (isset purchase criteria fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [ + { + id: '12', + name: 'monitor', + quantity: 10 + } + ], + total: 50, + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '96', + name: 'Purchase', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'shoppingCartItems', + fieldType: 'object', + comparatorType: 'IsSet', + dataType: 'purchase', + id: 1, + value: '' + }, + { + field: 'shoppingCartItems.price', + fieldType: 'double', + comparatorType: 'IsSet', + dataType: 'purchase', + id: 3, + value: '' + }, + { + field: 'shoppingCartItems.name', + fieldType: 'string', + comparatorType: 'IsSet', + dataType: 'purchase', + id: 5, + value: '' + }, + { + field: 'total', + fieldType: 'double', + comparatorType: 'IsSet', + dataType: 'purchase', + id: 7, + value: '' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 95 if isset updateCart criteria is matched', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ id: '12', name: 'Mocha', price: 50, quantity: 50 }], + eventType: 'cartUpdate' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '95', + name: 'UpdateCart: isSet Comparator', + createdAt: 1719328291857, + updatedAt: 1719328291857, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart', + comparatorType: 'IsSet', + value: '', + fieldType: 'object' + }, + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.name', + comparatorType: 'IsSet', + value: '', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.price', + comparatorType: 'IsSet', + value: '', + fieldType: 'double' + }, + { + dataType: 'customEvent', + field: + 'updateCart.updatedShoppingCartItems.quantity', + comparatorType: 'IsSet', + value: '', + fieldType: 'long' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('95'); + }); + + it('should return null (isset updateCart criteria fail)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ id: '12', name: 'Mocha', quantity: 50 }], + eventType: 'cartUpdate' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '95', + name: 'UpdateCart: isSet Comparator', + createdAt: 1719328291857, + updatedAt: 1719328291857, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'customEvent', + field: 'updateCart', + comparatorType: 'IsSet', + value: '', + fieldType: 'object' + }, + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.name', + comparatorType: 'IsSet', + value: '', + fieldType: 'string' + }, + { + dataType: 'customEvent', + field: 'updateCart.updatedShoppingCartItems.price', + comparatorType: 'IsSet', + value: '', + fieldType: 'double' + }, + { + dataType: 'customEvent', + field: + 'updateCart.updatedShoppingCartItems.quantity', + comparatorType: 'IsSet', + value: '', + fieldType: 'long' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 100 (boolean test)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + subscribed: true, + phoneNumber: '99999999' + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '100', + name: 'User', + createdAt: 1716560453973, + updatedAt: 1716560453973, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + field: 'subscribed', + fieldType: 'boolean', + comparatorType: 'Equals', + dataType: 'user', + id: 25, + value: 'true' + }, + { + field: 'phoneNumber', + fieldType: 'String', + comparatorType: 'IsSet', + dataType: 'user', + id: 28, + value: '' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('100'); + }); + + it('should return criteriaId 194 if Contact: Phone Number != 57688559', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + subscribed: true, + phoneNumber: '123685748641' + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '194', + name: 'Contact: Phone Number != 57688559', + createdAt: 1721337331194, + updatedAt: 1722338525737, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'phoneNumber', + comparatorType: 'DoesNotEqual', + value: '57688559', + fieldType: 'string' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('194'); + }); + + it('should return criteriaId 293 if Contact: subscribed != false', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + subscribed: true, + phoneNumber: '123685748641' + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '293', + name: 'Contact: subscribed != false', + createdAt: 1722605666776, + updatedAt: 1722606283109, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'user', + field: 'subscribed', + comparatorType: 'DoesNotEqual', + value: 'false', + fieldType: 'boolean' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('293'); + }); + + it('should return criteriaId 297 if Purchase: shoppingCartItems.quantity != 12345678', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [ + { + id: '12', + name: 'monitor', + price: 50, + quantity: 10 + } + ], + total: 50, + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '297', + name: 'Purchase: shoppingCartItems.quantity != 12345678', + createdAt: 1722667099444, + updatedAt: 1722667361286, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.quantity', + comparatorType: 'DoesNotEqual', + value: '12345678', + fieldType: 'long' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('297'); + }); + + it('should return criteriaId 298 if Purchase: shoppingCartItems.price != 105', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [ + { + id: '12', + name: 'monitor', + price: 50.5, + quantity: 10 + } + ], + total: 50, + eventType: 'purchase' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify({ + count: 1, + criteriaSets: [ + { + criteriaId: '298', + name: 'Purchase: shoppingCartItems.price != 105', + createdAt: 1722606251607, + updatedAt: 1722606295791, + searchQuery: { + combinator: 'And', + searchQueries: [ + { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + searchCombo: { + combinator: 'And', + searchQueries: [ + { + dataType: 'purchase', + field: 'shoppingCartItems.price', + comparatorType: 'DoesNotEqual', + value: '105', + fieldType: 'double' + } + ] + } + } + ] + } + ] + } + } + ] + }) + ); + expect(result).toEqual('298'); + }); +}); diff --git a/src/anonymousUserTracking/tests/dataTypeComparatorSearchQueryCriteria.test.ts b/src/anonymousUserTracking/tests/dataTypeComparatorSearchQueryCriteria.test.ts new file mode 100644 index 00000000..e75cf9f4 --- /dev/null +++ b/src/anonymousUserTracking/tests/dataTypeComparatorSearchQueryCriteria.test.ts @@ -0,0 +1,439 @@ +import { SHARED_PREFS_EVENT_LIST_KEY } from '../../constants'; +import CriteriaCompletionChecker from '../criteriaCompletionChecker'; +import { + DATA_TYPE_COMPARATOR_DOES_NOT_EQUAL, + DATA_TYPE_COMPARATOR_EQUALS, + DATA_TYPE_COMPARATOR_GREATER_THAN, + DATA_TYPE_COMPARATOR_GREATER_THAN_OR_EQUAL_TO, + DATA_TYPE_COMPARATOR_IS_SET, + DATA_TYPE_COMPARATOR_LESS_THAN, + DATA_TYPE_COMPARATOR_LESS_THAN_OR_EQUAL_TO +} from './constants'; + +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn() +}; + +describe('dataTypeComparatorSearchQueryCriteria', () => { + beforeEach(() => { + (global as any).localStorage = localStorageMock; + }); + + it('should return criteriaId 285 (Comparator test For Equal)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + savings: 19.99, + likes_boba: true, + country: 'Chaina', + eventTimeStamp: 3 + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(DATA_TYPE_COMPARATOR_EQUALS) + ); + expect(result).toEqual('285'); + }); + + it('should return null (Comparator test For Equal - No Match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + savings: 10.99, + eventTimeStamp: 30, + likes_boba: false, + country: 'Taiwan' + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(DATA_TYPE_COMPARATOR_EQUALS) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 285 (Comparator test For DoesNotEqual)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + savings: 11.2, + eventTimeStamp: 30, + likes_boba: false + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(DATA_TYPE_COMPARATOR_DOES_NOT_EQUAL) + ); + expect(result).toEqual('285'); + }); + + it('should return null (Comparator test For DoesNotEqual - No Match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + savings: 10.99, + eventTimeStamp: 30, + likes_boba: true + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(DATA_TYPE_COMPARATOR_DOES_NOT_EQUAL) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 289 (Comparator test For LessThan)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + savings: 10, + eventTimeStamp: 14 + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(DATA_TYPE_COMPARATOR_LESS_THAN) + ); + expect(result).toEqual('289'); + }); + + it('should return null (Comparator test For LessThan - No Match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + savings: 10, + eventTimeStamp: 18 + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(DATA_TYPE_COMPARATOR_LESS_THAN) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 290 (Comparator test For LessThanOrEqualTo)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + savings: 17, + eventTimeStamp: 14 + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(DATA_TYPE_COMPARATOR_LESS_THAN_OR_EQUAL_TO) + ); + expect(result).toEqual('290'); + }); + + it('should return null (Comparator test For LessThanOrEqualTo - No Match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + savings: 18, + eventTimeStamp: 12 + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(DATA_TYPE_COMPARATOR_LESS_THAN_OR_EQUAL_TO) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 290 (Comparator test For GreaterThan)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + savings: 56, + eventTimeStamp: 51 + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(DATA_TYPE_COMPARATOR_GREATER_THAN) + ); + expect(result).toEqual('290'); + }); + + it('should return null (Comparator test For GreaterThan - No Match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + savings: 5, + eventTimeStamp: 3 + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(DATA_TYPE_COMPARATOR_GREATER_THAN) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 291 (Comparator test For GreaterThanOrEqualTo)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + savings: 20, + eventTimeStamp: 30 + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(DATA_TYPE_COMPARATOR_GREATER_THAN_OR_EQUAL_TO) + ); + expect(result).toEqual('291'); + }); + + it('should return null (Comparator test For GreaterThanOrEqualTo - No Match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + savings: 18, + eventTimeStamp: 16 + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(DATA_TYPE_COMPARATOR_GREATER_THAN_OR_EQUAL_TO) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 285 (Comparator test For IsSet)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + savings: 10, + eventTimeStamp: 20, + saved_cars: '10', + country: 'Taiwan' + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(DATA_TYPE_COMPARATOR_IS_SET) + ); + expect(result).toEqual('285'); + }); + + it('should return criteriaId 285 (Comparator test For IsSet - No Match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + savings: '', + eventTimeStamp: '', + saved_cars: 'd', + country: '' + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(DATA_TYPE_COMPARATOR_IS_SET) + ); + expect(result).toEqual(null); + }); +}); diff --git a/src/anonymousUserTracking/tests/nestedTesting.test.ts b/src/anonymousUserTracking/tests/nestedTesting.test.ts new file mode 100644 index 00000000..207c9328 --- /dev/null +++ b/src/anonymousUserTracking/tests/nestedTesting.test.ts @@ -0,0 +1,469 @@ +import { SHARED_PREFS_EVENT_LIST_KEY } from '../../constants'; +import CriteriaCompletionChecker from '../criteriaCompletionChecker'; +import { + NESTED_CRITERIA, + NESTED_CRITERIA_MULTI_LEVEL, + NESTED_CRITERIA_MULTI_LEVEL_ARRAY, + NESTED_CRITERIA_MULTI_LEVEL_ARRAY_TRACK_EVENT, + NESTED_CRITERIA_MULTI_LEVEL_MORE_THAN_4_EVENTS +} from './constants'; + +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn() +}; + +describe('nestedTesting', () => { + beforeEach(() => { + (global as any).localStorage = localStorageMock; + }); + + it('should return criteriaId 168 (nested field)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + email: 'user@example.com', + furniture: [ + { + furnitureType: 'Sofa', + furnitureColor: 'White', + lengthInches: 40, + widthInches: 60 + }, + { + furnitureType: 'table', + furnitureColor: 'Gray', + lengthInches: 20, + widthInches: 30 + } + ] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria(JSON.stringify(NESTED_CRITERIA)); + expect(result).toEqual('168'); + }); + + it('should return criteriaId null (nested field - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + email: 'user@example.com', + furniture: [ + { + furnitureType: 'Sofa', + furnitureColor: 'Gray', + lengthInches: 40, + widthInches: 60 + }, + { + furnitureType: 'table', + furnitureColor: 'White', + lengthInches: 20, + widthInches: 30 + } + ] + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria(JSON.stringify(NESTED_CRITERIA)); + expect(result).toEqual(null); + }); + + it('should return criteriaId 425 (Multi level Nested field criteria)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + eventName: 'button-clicked', + dataFields: { + browserVisit: { website: { domain: 'https://mybrand.com/socks' } } + }, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(NESTED_CRITERIA_MULTI_LEVEL) + ); + expect(result).toEqual('425'); + }); + + it('should return criteriaId 425 (Multi level Nested field criteria - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + eventName: 'button-clicked', + dataFields: { + 'button-clicked': { + browserVisit: { + website: { domain: 'https://mybrand.com/socks' } + } + } + }, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(NESTED_CRITERIA_MULTI_LEVEL) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId null (Multi level Nested field criteria - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + eventName: 'button-clicked', + dataFields: { + 'browserVisit.website.domain': 'https://mybrand.com/socks' + }, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(NESTED_CRITERIA_MULTI_LEVEL) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId null (Multi level Nested field criteria - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + eventName: 'button-clicked', + dataFields: { + browserVisit: { website: { domain: 'https://mybrand.com' } } + }, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(NESTED_CRITERIA_MULTI_LEVEL) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId null (Multi level Nested field criteria - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + eventName: 'button-clicked', + dataFields: { + quantity: 11, + domain: 'https://mybrand.com/socks' + }, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(NESTED_CRITERIA_MULTI_LEVEL) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 436 (Multi level Nested field criteria)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + furniture: { + material: [ + { + type: 'table', + color: 'black', + lengthInches: 40, + widthInches: 60 + }, + { + type: 'Sofa', + color: 'Gray', + lengthInches: 20, + widthInches: 30 + } + ] + } + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(NESTED_CRITERIA_MULTI_LEVEL_ARRAY) + ); + expect(result).toEqual('436'); + }); + + it('should return criteriaId null (Multi level Nested field criteria - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + dataFields: { + furniture: { + material: [ + { + type: 'table', + color: 'Gray', + lengthInches: 40, + widthInches: 60 + }, + { + type: 'Sofa', + color: 'black', + lengthInches: 20, + widthInches: 30 + } + ] + } + }, + eventType: 'user' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(NESTED_CRITERIA_MULTI_LEVEL_ARRAY) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 459 (Multi level Nested field criteria)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + eventName: 'TopLevelArrayObject', + dataFields: { + a: { + h: [ + { + b: 'e', + c: 'h' + }, + { + b: 'd', + c: 'g' + } + ] + } + }, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(NESTED_CRITERIA_MULTI_LEVEL_ARRAY_TRACK_EVENT) + ); + expect(result).toEqual('459'); + }); + + it('should return criteriaId null (Multi level Nested field criteria - No match)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + eventName: 'TopLevelArrayObject', + dataFields: { + a: { + h: [ + { + b: 'd', + c: 'h' + }, + { + b: 'e', + c: 'g' + } + ] + } + }, + eventType: 'customEvent' + } + ]); + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(NESTED_CRITERIA_MULTI_LEVEL_ARRAY_TRACK_EVENT) + ); + expect(result).toEqual(null); + }); + + it('should return criteriaId 484 (Multi level Nested field criteria for more than 3 events)', () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + items: [{ id: '12', name: 'monitor', price: 50, quantity: 10 }], + total: 50, + eventType: 'purchase' + }, + { + items: [ + { name: 'piano', id: 'fdsafds', price: 100, quantity: 2 }, + { name: 'piano2', id: 'fdsafds2', price: 100, quantity: 5 } + ], + eventType: 'cartUpdate', + preferUserId: true, + createdAt: 1729585174 + }, + { likes_boba: 'true', eventType: 'user' }, + { + eventName: 'cancelled_booking', + createdAt: 1729585183, + dataFields: { details: { event: { name: 'dummy' } } }, + createNewFields: true, + eventType: 'customEvent' + }, + { + eventName: 'cancelled_booking', + createdAt: 1729585192, + dataFields: { details: { event: { name: 'haircut' } } }, + createNewFields: true, + eventType: 'customEvent' + } + ]); + } + + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(NESTED_CRITERIA_MULTI_LEVEL_MORE_THAN_4_EVENTS) + ); + expect(result).toEqual('484'); + }); +}); diff --git a/src/anonymousUserTracking/tests/userMergeScenarios.test.ts b/src/anonymousUserTracking/tests/userMergeScenarios.test.ts new file mode 100644 index 00000000..88dcce04 --- /dev/null +++ b/src/anonymousUserTracking/tests/userMergeScenarios.test.ts @@ -0,0 +1,953 @@ +import MockAdapter from 'axios-mock-adapter'; +import { GenerateJWTPayload, initializeWithConfig } from '../../authorization'; +import { + SHARED_PREFS_EVENT_LIST_KEY, + SHARED_PREFS_CRITERIA, + GETMESSAGES_PATH, + ENDPOINT_TRACK_ANON_SESSION, + GET_CRITERIA_PATH, + SHARED_PREFS_ANON_SESSIONS, + ENDPOINT_MERGE_USER, + SHARED_PREF_ANON_USER_ID, + SHARED_PREF_ANON_USAGE_TRACKED, + SHARED_PREF_USER_TOKEN +} from '../../constants'; +import { track } from '../../events'; +import { getInAppMessages } from '../../inapp'; +import { baseAxiosRequest } from '../../request'; +import { USER_MERGE_SCENARIO_CRITERIA } from './constants'; +import { setTypeOfAuth } from '../../utils/typeOfAuth'; + +jest.setTimeout(20000); // Set the timeout to 10 seconds + +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn() +}; + +const eventData = { + eventName: 'testEvent123', + dataFields: undefined, + createNewFields: true, + eventType: 'customEvent' +}; + +const eventDataMatched = { + eventName: 'testEvent', + dataFields: undefined, + createNewFields: true, + eventType: 'customEvent' +}; + +const initialAnonSessionInfo = { + itbl_anon_sessions: { + number_of_sessions: 1, + first_session: 123456789, + last_session: expect.any(Number) + } +}; + +declare global { + function uuidv4(): string; + function getEmail(): string; + function getUserID(): string; + function setUserID(): string; +} +const mockRequest = new MockAdapter(baseAxiosRequest); +// const mockOnPostSpy = jest.spyOn(mockRequest, 'onPost'); + +const MOCK_JWT_KEY = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MzA2MTc3MzQsImlhdCI6MTYzMDYxNzQzNCwiZW1haWwiOiJ3aWR0aC50ZXN0ZXJAZ21haWwuY29tIn0.knLmbgO8kKM9CHP2TH2v85OSC2Jorh2JjRm76FFsPQc'; +const MOCK_JWT_KEY_WITH_ONE_MINUTE_EXPIRY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJleHAiOjE2Nzk0ODMyOTEsImlhdCI6MTY3OTQ4MzIzMX0.APaQAYy-lTE0o8rbR6b6-28eCICq36SQMBXmeZAvk1k'; + +describe('UserMergeScenariosTests', () => { + beforeAll(() => { + (global as any).localStorage = localStorageMock; + global.window = Object.create({ location: { hostname: 'google.com' } }); + mockRequest.onGet(GETMESSAGES_PATH).reply(200, { + data: 'something' + }); + mockRequest.onPost('/events/track').reply(200, {}); + mockRequest.onPost(ENDPOINT_MERGE_USER).reply(200, {}); + mockRequest.onGet(GET_CRITERIA_PATH).reply(200, {}); + mockRequest.onPost(ENDPOINT_TRACK_ANON_SESSION).reply(200, {}); + }); + + beforeEach(() => { + mockRequest.reset(); + mockRequest.resetHistory(); + mockRequest.onGet(GETMESSAGES_PATH).reply(200, { + data: 'something' + }); + mockRequest.onPost('/events/track').reply(200, {}); + mockRequest.onPost(ENDPOINT_MERGE_USER).reply(200, {}); + mockRequest.onGet(GET_CRITERIA_PATH).reply(200, {}); + mockRequest.onPost(ENDPOINT_TRACK_ANON_SESSION).reply(200, {}); + jest.resetAllMocks(); + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([eventData]); + } + if (key === SHARED_PREFS_CRITERIA) { + return JSON.stringify(USER_MERGE_SCENARIO_CRITERIA); + } + if (key === SHARED_PREFS_ANON_SESSIONS) { + return JSON.stringify(initialAnonSessionInfo); + } + if (key === SHARED_PREF_ANON_USAGE_TRACKED) { + return 'true'; + } + return null; + }); + jest.useFakeTimers(); + }); + + describe('UserMergeScenariosTests with setUserID', () => { + it('criteria not met with merge false with setUserId', async () => { + const { setUserID, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true, + identityResolution: { + replayOnVisitorToKnown: false, + mergeOnAnonymousToKnown: false + } + } + }); + logout(); // logout to remove logged in users before this test + try { + await track({ eventName: 'testEvent123' }); + } catch (e) { + console.log(''); + } + expect(localStorage.setItem).toHaveBeenCalledWith( + SHARED_PREFS_EVENT_LIST_KEY, + expect.any(String) + ); + await setUserID('testuser123'); + const response = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(response.config.params.userId).toBe('testuser123'); + const removeItemCalls = localStorageMock.removeItem.mock.calls.filter( + (call) => call[0] === SHARED_PREFS_EVENT_LIST_KEY + ); + // count 2 is because we want to remove the anon user and remove anon details + expect(removeItemCalls.length).toBe(2); + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeUndefined(); // ensure that merge API Do NOT get called + }); + + it('criteria not met with merge true with setUserId', async () => { + const { setUserID, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true, + identityResolution: { + replayOnVisitorToKnown: true, + mergeOnAnonymousToKnown: true + } + } + }); + logout(); // logout to remove logged in users before this test + try { + await track({ ...eventData }); + } catch (e) { + console.log(''); + } + expect(localStorage.setItem).toHaveBeenCalledWith( + SHARED_PREFS_EVENT_LIST_KEY, + expect.any(String) + ); + await setUserID('testuser123'); + const response = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(response.config.params.userId).toBe('testuser123'); + const removeItemCalls = localStorageMock.removeItem.mock.calls.filter( + (call) => call[0] === SHARED_PREFS_EVENT_LIST_KEY + ); + // count 2 means it removed items and so syncEvents was called + // because removeItem gets called one time for + // the key in case of logout and 2nd time on syncevents + expect(removeItemCalls.length).toBe(2); + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeUndefined(); // ensure that merge API Do NOT get called + }); + + it('criteria not met with merge default value with setUserId', async () => { + const { setUserID, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true, + identityResolution: { + replayOnVisitorToKnown: true, + mergeOnAnonymousToKnown: false + } + } + }); + logout(); // logout to remove logged in users before this test + try { + await track({ eventName: 'testEvent123' }); + } catch (e) { + console.log(''); + } + expect(localStorage.setItem).toHaveBeenCalledWith( + SHARED_PREFS_EVENT_LIST_KEY, + expect.any(String) + ); + await setUserID('testuser123'); + const response = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(response.config.params.userId).toBe('testuser123'); + const removeItemCalls = localStorageMock.removeItem.mock.calls.filter( + (call) => call[0] === SHARED_PREFS_EVENT_LIST_KEY + ); + // count 2 means it removed items and so syncEvents was called + + // because removeItem gets called one time for + // the key in case of logout and 2nd time on syncevents + expect(removeItemCalls.length).toBe(2); + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeUndefined(); // ensure that merge API Do NOT get called + }); + + it('criteria is met with merge false with setUserId', async () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([eventDataMatched]); + } + if (key === SHARED_PREFS_CRITERIA) { + return JSON.stringify(USER_MERGE_SCENARIO_CRITERIA); + } + if (key === SHARED_PREFS_ANON_SESSIONS) { + return JSON.stringify(initialAnonSessionInfo); + } + return null; + }); + const { setUserID, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true, + identityResolution: { + replayOnVisitorToKnown: true, + mergeOnAnonymousToKnown: false + } + } + }); + logout(); // logout to remove logged in users before this test + try { + await track({ eventName: 'testEvent' }); + } catch (e) { + console.log(''); + } + await setUserID('testuser123'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith( + SHARED_PREF_ANON_USER_ID + ); + const response = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(response.config.params.userId).toBe('testuser123'); + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeUndefined(); // ensure that merge API Do NOT get called + }); + + it('criteria is met with merge true with setUserId', async () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([eventDataMatched]); + } + if (key === SHARED_PREFS_CRITERIA) { + return JSON.stringify(USER_MERGE_SCENARIO_CRITERIA); + } + if (key === SHARED_PREFS_ANON_SESSIONS) { + return JSON.stringify(initialAnonSessionInfo); + } + if (key === SHARED_PREF_ANON_USAGE_TRACKED) { + return 'true'; + } + return null; + }); + const { setUserID, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true, + identityResolution: { + replayOnVisitorToKnown: true, + mergeOnAnonymousToKnown: true + } + } + }); + logout(); // logout to remove logged in users before this test + try { + await track({ eventName: 'testEvent' }); + } catch (e) { + console.log('', e); + } + // this function call is needed for putting some delay before executing setUserId + try { + await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + } catch (e) { + console.log(e); + } + await setUserID('testuser123'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith( + SHARED_PREF_ANON_USER_ID + ); + jest.useFakeTimers(); + setTimeout(() => { + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeDefined(); // ensure that merge API gets called + }, 1000); + jest.runAllTimers(); + }); + + it('criteria is met with merge default with setUserId', async () => { + const anonId = '123e4567-e89b-12d3-a456-426614174000'; + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([eventDataMatched]); + } + if (key === SHARED_PREFS_CRITERIA) { + return JSON.stringify(USER_MERGE_SCENARIO_CRITERIA); + } + if (key === SHARED_PREFS_ANON_SESSIONS) { + return JSON.stringify(initialAnonSessionInfo); + } + if (key === SHARED_PREF_ANON_USER_ID) { + return anonId; + } + return null; + }); + const { setUserID, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true + } + }); + logout(); // logout to remove logged in users before this test + try { + await track({ eventName: 'testEvent' }); + } catch (e) { + console.log('', e); + } + try { + await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + } catch (e) { + console.log(e); + } + setTypeOfAuth('userID'); + await setUserID('testuser123'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith( + SHARED_PREF_ANON_USER_ID + ); + jest.useFakeTimers(); + setTimeout(() => { + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeDefined(); // ensure that merge API gets called + }, 1000); + jest.runAllTimers(); + }); + + it('current user identified with setUserId merge false', async () => { + const { setUserID, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true, + identityResolution: { + replayOnVisitorToKnown: true, + mergeOnAnonymousToKnown: false + } + } + }); + logout(); // logout to remove logged in users before this test + await setUserID('testuser123'); + const response = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(response.config.params.userId).toBe('testuser123'); + try { + await track({ eventName: 'testEvent' }); + } catch (e) { + console.log('', e); + } + expect(localStorageMock.setItem).not.toHaveBeenCalledWith( + SHARED_PREF_ANON_USER_ID + ); + await setUserID('testuseranotheruser'); + const secondResponse = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(secondResponse.config.params.userId).toBe('testuseranotheruser'); + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeUndefined(); // ensure that merge API Do NOT get called + }); + + it('current user identified with setUserId merge true', async () => { + const { setUserID, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true, + identityResolution: { + replayOnVisitorToKnown: true, + mergeOnAnonymousToKnown: true + } + } + }); + logout(); // logout to remove logged in users before this test + await setUserID('testuser123'); + const response = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(response.config.params.userId).toBe('testuser123'); + expect(localStorageMock.setItem).not.toHaveBeenCalledWith( + SHARED_PREF_ANON_USER_ID + ); + await setUserID('testuseranotheruser'); + const secondResponse = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(secondResponse.config.params.userId).toBe('testuseranotheruser'); + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeDefined(); // ensure that merge API gets called + }); + + it('merge api called with destination userID JWT Authorization', async () => { + const { setUserID, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true, + identityResolution: { + replayOnVisitorToKnown: true, + mergeOnAnonymousToKnown: true + } + }, + generateJWT: (payload: GenerateJWTPayload) => { + if (payload.userID === 'testuseranotheruser') { + return Promise.resolve(MOCK_JWT_KEY_WITH_ONE_MINUTE_EXPIRY); + } + return Promise.resolve(MOCK_JWT_KEY); + } + }); + logout(); // logout to remove logged in users before this test + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREF_USER_TOKEN) { + return MOCK_JWT_KEY; + } + return null; + }); + await setUserID('testuser123'); + const response = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(response.config.headers.Authorization).toBe( + `Bearer ${MOCK_JWT_KEY}` + ); + expect(response.config.params.userId).toBe('testuser123'); + expect(localStorageMock.setItem).not.toHaveBeenCalledWith( + SHARED_PREF_ANON_USER_ID + ); + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREF_USER_TOKEN) { + return MOCK_JWT_KEY_WITH_ONE_MINUTE_EXPIRY; + } + return null; + }); + await setUserID('testuseranotheruser'); + const secondResponse = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(secondResponse.config.headers.Authorization).toBe( + `Bearer ${MOCK_JWT_KEY_WITH_ONE_MINUTE_EXPIRY}` + ); + expect(secondResponse.config.params.userId).toBe('testuseranotheruser'); + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData?.headers?.Authorization).toBe( + `Bearer ${MOCK_JWT_KEY_WITH_ONE_MINUTE_EXPIRY}` + ); + expect(mergePostRequestData).toBeDefined(); // ensure that merge API gets called + logout(); // logout to remove logged in users after this test + }); + + it('current user identified with setUserId merge default', async () => { + const { setUserID, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true, + identityResolution: { + replayOnVisitorToKnown: true, + mergeOnAnonymousToKnown: false + } + } + }); + logout(); // logout to remove logged in users before this test + await setUserID('testuser123'); + const response = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(response.config.params.userId).toBe('testuser123'); + expect(localStorageMock.setItem).not.toHaveBeenCalledWith( + SHARED_PREF_ANON_USER_ID + ); + await setUserID('testuseranotheruser'); + const secondResponse = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(secondResponse.config.params.userId).toBe('testuseranotheruser'); + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeUndefined(); // ensure that merge API Do NOT get called + }); + }); + + describe('UserMergeScenariosTests with setEmail', () => { + it('criteria not met with merge false with setEmail', async () => { + const { setEmail, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true, + identityResolution: { + replayOnVisitorToKnown: false, + mergeOnAnonymousToKnown: false + } + } + }); + logout(); // logout to remove logged in users before this test + try { + await track({ eventName: 'testEvent123' }); + } catch (e) { + console.log(''); + } + expect(localStorage.setItem).toHaveBeenCalledWith( + SHARED_PREFS_EVENT_LIST_KEY, + expect.any(String) + ); + await setEmail('testuser123@test.com'); + const response = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(response.config.params.email).toBe('testuser123@test.com'); + const removeItemCalls = localStorageMock.removeItem.mock.calls.filter( + (call) => call[0] === SHARED_PREFS_EVENT_LIST_KEY + ); + // count 2 is because we want to remove the anon user and remove anon details + expect(removeItemCalls.length).toBe(2); + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeUndefined(); // ensure that merge API Do NOT get called + }); + + it('criteria not met with merge true with setEmail', async () => { + const { setEmail, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true, + identityResolution: { + replayOnVisitorToKnown: true, + mergeOnAnonymousToKnown: true + } + } + }); + logout(); // logout to remove logged in users before this test + try { + await track({ ...eventData }); + } catch (e) { + console.log(''); + } + expect(localStorage.setItem).toHaveBeenCalledWith( + SHARED_PREFS_EVENT_LIST_KEY, + expect.any(String) + ); + await setEmail('testuser123@test.com'); + const response = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(response.config.params.email).toBe('testuser123@test.com'); + const removeItemCalls = localStorageMock.removeItem.mock.calls.filter( + (call) => call[0] === SHARED_PREFS_EVENT_LIST_KEY + ); + // count 2 means it removed items and so syncEvents was called + + // because removeItem gets called one time for + // the key in case of logout and 2nd time on syncevents + expect(removeItemCalls.length).toBe(2); + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeUndefined(); // ensure that merge API Do NOT get called + }); + + it('criteria not met with merge default value with setEmail', async () => { + const { setEmail, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true + } + }); + logout(); // logout to remove logged in users before this test + try { + await track({ eventName: 'testEvent123' }); + } catch (e) { + console.log(''); + } + expect(localStorage.setItem).toHaveBeenCalledWith( + SHARED_PREFS_EVENT_LIST_KEY, + expect.any(String) + ); + await setEmail('testuser123@test.com'); + const response = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(response.config.params.email).toBe('testuser123@test.com'); + const removeItemCalls = localStorageMock.removeItem.mock.calls.filter( + (call) => call[0] === SHARED_PREFS_EVENT_LIST_KEY + ); + // count 2 means it removed items and so syncEvents was called + + // because removeItem gets called one time for + // the key in case of logout and 2nd time on syncevents + expect(removeItemCalls.length).toBe(2); + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeUndefined(); // ensure that merge API Do NOT get called + }); + + it('criteria is met with merge true with setEmail', async () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([eventDataMatched]); + } + if (key === SHARED_PREFS_CRITERIA) { + return JSON.stringify(USER_MERGE_SCENARIO_CRITERIA); + } + if (key === SHARED_PREFS_ANON_SESSIONS) { + return JSON.stringify(initialAnonSessionInfo); + } + if (key === SHARED_PREF_ANON_USAGE_TRACKED) { + return 'true'; + } + return null; + }); + const { setEmail } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true, + identityResolution: { + replayOnVisitorToKnown: true, + mergeOnAnonymousToKnown: true + } + } + }); + try { + await track({ eventName: 'testEvent' }); + } catch (e) { + console.log('', e); + } + try { + await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + } catch (e) { + console.log(e); + } + await setEmail('testuser123@test.com'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith( + SHARED_PREF_ANON_USER_ID + ); + jest.useFakeTimers(); + setTimeout(() => { + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeDefined(); // ensure that merge API gets called + }, 1500); + jest.runAllTimers(); + }); + + it('criteria is met with merge default with setEmail', async () => { + const anonId = '123e4567-e89b-12d3-a456-426614174000'; + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([eventDataMatched]); + } + if (key === SHARED_PREFS_CRITERIA) { + return JSON.stringify(USER_MERGE_SCENARIO_CRITERIA); + } + if (key === SHARED_PREFS_ANON_SESSIONS) { + return JSON.stringify(initialAnonSessionInfo); + } + if (key === SHARED_PREF_ANON_USER_ID) { + return anonId; + } + return null; + }); + const { setEmail, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true + } + }); + logout(); // logout to remove logged in users before this test + try { + await track({ eventName: 'testEvent' }); + } catch (e) { + console.log('', e); + } + try { + await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + } catch (e) { + console.log(e); + } + setTypeOfAuth('userID'); + await setEmail('testuser123@test.com'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith( + SHARED_PREF_ANON_USER_ID + ); + jest.useFakeTimers(); + setTimeout(() => { + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeDefined(); // ensure that merge API gets called + }, 1500); + jest.runAllTimers(); + }); + + it('current user identified with setEmail with merge false', async () => { + const { setEmail, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true, + identityResolution: { + replayOnVisitorToKnown: true, + mergeOnAnonymousToKnown: false + } + } + }); + logout(); // logout to remove logged in users before this test + await setEmail('testuser123@test.com'); + const response = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(response.config.params.email).toBe('testuser123@test.com'); + try { + await track({ eventName: 'testEvent' }); + } catch (e) { + console.log('', e); + } + expect(localStorageMock.setItem).not.toHaveBeenCalledWith( + SHARED_PREF_ANON_USER_ID + ); + await setEmail('testuseranotheruser@test.com'); + const secondResponse = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(secondResponse.config.params.email).toBe( + 'testuseranotheruser@test.com' + ); + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeUndefined(); // ensure that merge API Do NOT get called + }); + + it('current user identified with setEmail merge true', async () => { + const { setEmail, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true, + identityResolution: { + replayOnVisitorToKnown: true, + mergeOnAnonymousToKnown: true + } + } + }); + logout(); // logout to remove logged in users before this test + await setEmail('testuser123@test.com'); + const response = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(response.config.params.email).toBe('testuser123@test.com'); + try { + await track({ eventName: 'testEvent' }); + } catch (e) { + console.log('', e); + } + expect(localStorageMock.setItem).not.toHaveBeenCalledWith( + SHARED_PREF_ANON_USER_ID + ); + await setEmail('testuseranotheruser@test.com'); + const secondResponse = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(secondResponse.config.params.email).toBe( + 'testuseranotheruser@test.com' + ); + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeDefined(); // ensure that merge API gets called + }); + + it('current user identified with setEmail merge default', async () => { + const { setEmail, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true, + identityResolution: { + replayOnVisitorToKnown: true, + mergeOnAnonymousToKnown: false + } + } + }); + logout(); // logout to remove logged in users before this test + await setEmail('testuser123@test.com'); + const response = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(response.config.params.email).toBe('testuser123@test.com'); + try { + await track({ eventName: 'testEvent' }); + } catch (e) { + console.log('', e); + } + expect(localStorageMock.setItem).not.toHaveBeenCalledWith( + SHARED_PREF_ANON_USER_ID + ); + await setEmail('testuseranotheruser@test.com'); + const secondResponse = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(secondResponse.config.params.email).toBe( + 'testuseranotheruser@test.com' + ); + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData).toBeUndefined(); // ensure that merge API Do NOT get called + }); + + it('merge api called with destination email JWT Authorization', async () => { + const { setEmail, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { + enableAnonActivation: true, + identityResolution: { + replayOnVisitorToKnown: true, + mergeOnAnonymousToKnown: true + } + }, + generateJWT: (payload: GenerateJWTPayload) => { + if (payload.email === 'testuseranotheruser@test.com') { + return Promise.resolve(MOCK_JWT_KEY_WITH_ONE_MINUTE_EXPIRY); + } + return Promise.resolve(MOCK_JWT_KEY); + } + }); + logout(); // logout to remove logged in users before this test + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREF_USER_TOKEN) { + return MOCK_JWT_KEY; + } + return null; + }); + await setEmail('testuser123@test.com'); + const response = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(response.config.headers.Authorization).toBe( + `Bearer ${MOCK_JWT_KEY}` + ); + expect(response.config.params.email).toBe('testuser123@test.com'); + try { + await track({ eventName: 'testEvent' }); + } catch (e) { + console.log('', e); + } + expect(localStorageMock.setItem).not.toHaveBeenCalledWith( + SHARED_PREF_ANON_USER_ID + ); + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREF_USER_TOKEN) { + return MOCK_JWT_KEY_WITH_ONE_MINUTE_EXPIRY; + } + return null; + }); + await setEmail('testuseranotheruser@test.com'); + const secondResponse = await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + expect(secondResponse.config.headers.Authorization).toBe( + `Bearer ${MOCK_JWT_KEY_WITH_ONE_MINUTE_EXPIRY}` + ); + expect(secondResponse.config.params.email).toBe( + 'testuseranotheruser@test.com' + ); + const mergePostRequestData = mockRequest.history.post.find( + (req) => req.url === ENDPOINT_MERGE_USER + ); + expect(mergePostRequestData?.headers?.Authorization).toBe( + `Bearer ${MOCK_JWT_KEY_WITH_ONE_MINUTE_EXPIRY}` + ); + expect(mergePostRequestData).toBeDefined(); // ensure that merge API gets called + }); + }); +}); diff --git a/src/anonymousUserTracking/tests/userUpdate.test.ts b/src/anonymousUserTracking/tests/userUpdate.test.ts new file mode 100644 index 00000000..579cffb6 --- /dev/null +++ b/src/anonymousUserTracking/tests/userUpdate.test.ts @@ -0,0 +1,146 @@ +import MockAdapter from 'axios-mock-adapter'; +import { baseAxiosRequest } from '../../request'; +import { + SHARED_PREFS_ANON_SESSIONS, + SHARED_PREFS_EVENT_LIST_KEY, + SHARED_PREFS_CRITERIA, + GET_CRITERIA_PATH, + ENDPOINT_TRACK_ANON_SESSION, + ENDPOINT_MERGE_USER, + SHARED_PREF_ANON_USAGE_TRACKED +} from '../../constants'; +import { updateUser } from '../../users'; +import { initializeWithConfig } from '../../authorization'; +import { CUSTOM_EVENT_API_TEST_CRITERIA } from './constants'; + +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn() +}; + +declare global { + function uuidv4(): string; + function getEmail(): string; + function getUserID(): string; + function setUserID(): string; +} + +const eventDataMatched = { + eventName: 'animal-found', + dataFields: { + type: 'cat', + count: 6, + vaccinated: true + }, + createNewFields: true, + eventType: 'customEvent' +}; + +const userDataMatched = { + dataFields: { + furniture: { + furnitureType: 'Sofa', + furnitureColor: 'White' + } + }, + eventType: 'user' +}; + +const initialAnonSessionInfo = { + itbl_anon_sessions: { + number_of_sessions: 1, + first_session: 123456789, + last_session: expect.any(Number) + } +}; + +const mockRequest = new MockAdapter(baseAxiosRequest); + +describe('UserUpdate', () => { + beforeAll(() => { + (global as any).localStorage = localStorageMock; + global.window = Object.create({ location: { hostname: 'google.com' } }); + mockRequest.onPost('/events/track').reply(200, {}); + mockRequest.onPost('/users/update').reply(200, {}); + mockRequest.onGet(GET_CRITERIA_PATH).reply(200, {}); + mockRequest.onPost(ENDPOINT_TRACK_ANON_SESSION).reply(200, {}); + }); + + beforeEach(() => { + mockRequest.reset(); + mockRequest.resetHistory(); + mockRequest.onPost('/events/track').reply(200, {}); + mockRequest.onPost('/users/update').reply(200, {}); + mockRequest.onPost(ENDPOINT_MERGE_USER).reply(200, {}); + mockRequest.onGet(GET_CRITERIA_PATH).reply(200, {}); + mockRequest.onPost(ENDPOINT_TRACK_ANON_SESSION).reply(200, {}); + jest.resetAllMocks(); + jest.useFakeTimers(); + }); + + it('should not have unnecessary extra nesting when locally stored user update fields are sent to server', async () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + ...userDataMatched.dataFields, + eventType: userDataMatched.eventType + }, + eventDataMatched + ]); + } + if (key === SHARED_PREFS_CRITERIA) { + return JSON.stringify(CUSTOM_EVENT_API_TEST_CRITERIA); + } + if (key === SHARED_PREFS_ANON_SESSIONS) { + return JSON.stringify(initialAnonSessionInfo); + } + if (key === SHARED_PREF_ANON_USAGE_TRACKED) { + return 'true'; + } + return null; + }); + + const { logout } = initializeWithConfig({ + authToken: '123', + configOptions: { enableAnonActivation: true } + }); + logout(); // logout to remove logged in users before this test + + try { + await updateUser(); + } catch (e) { + console.log(''); + } + expect(localStorage.setItem).toHaveBeenCalledWith( + SHARED_PREFS_EVENT_LIST_KEY, + expect.any(String) + ); + + const trackEvents = mockRequest.history.post.filter( + (req) => req.url === '/anonymoususer/events/session' + ); + + expect(trackEvents.length > 0).toBe(true); + + trackEvents.forEach((req) => { + const requestData = JSON.parse(String(req?.data)); + + expect(requestData).toHaveProperty('user'); + expect(requestData.user).toHaveProperty( + 'dataFields', + userDataMatched.dataFields + ); + expect(requestData.user.dataFields).toHaveProperty( + 'furniture', + userDataMatched.dataFields.furniture + ); + }); + + const trackEventsUserUpdate = mockRequest.history.post.filter( + (req) => req.url === '/users/update' + ); + expect(trackEventsUserUpdate.length === 0).toBe(true); + }); +}); diff --git a/src/anonymousUserTracking/tests/validateCustomEventUserUpdateAPI.test.ts b/src/anonymousUserTracking/tests/validateCustomEventUserUpdateAPI.test.ts new file mode 100644 index 00000000..c8e625dd --- /dev/null +++ b/src/anonymousUserTracking/tests/validateCustomEventUserUpdateAPI.test.ts @@ -0,0 +1,427 @@ +import MockAdapter from 'axios-mock-adapter'; +import { baseAxiosRequest } from '../../request'; +import { + SHARED_PREFS_ANON_SESSIONS, + SHARED_PREFS_EVENT_LIST_KEY, + SHARED_PREFS_CRITERIA, + ENDPOINT_MERGE_USER, + ENDPOINT_TRACK_ANON_SESSION, + GET_CRITERIA_PATH, + GETMESSAGES_PATH, + SHARED_PREF_ANON_USAGE_TRACKED +} from '../../constants'; +import { track } from '../../events'; +import { initializeWithConfig } from '../../authorization'; +import CriteriaCompletionChecker from '../criteriaCompletionChecker'; +import { updateUser } from '../../users'; +import { + CUSTOM_EVENT_API_TEST_CRITERIA, + USER_UPDATE_API_TEST_CRITERIA +} from './constants'; + +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn() +}; + +declare global { + function uuidv4(): string; + function getEmail(): string; + function getUserID(): string; + function setUserID(): string; +} + +// SUCCESS +const eventDataMatched = { + eventName: 'animal-found', + dataFields: { + type: 'cat', + count: 6, + vaccinated: true + }, + createNewFields: true, + eventType: 'customEvent' +}; + +// FAIL +const eventData = { + eventName: 'animal-found', + dataFields: { + type: 'cat', + count: 6, + vaccinated: true + }, + type: 'cat', + count: 6, + vaccinated: true, + createNewFields: true, + eventType: 'customEvent' +}; + +// SUCCESS +const userDataMatched = { + dataFields: { + furniture: { + furnitureType: 'Sofa', + furnitureColor: 'White' + } + }, + eventType: 'user' +}; + +// FAIL +const userData = { + dataFields: { + furniture: { + furnitureType: 'Sofa', + furnitureColor: 'White' + } + }, + furnitureType: 'Sofa', + furnitureColor: 'White', + eventType: 'user' +}; + +const initialAnonSessionInfo = { + itbl_anon_sessions: { + number_of_sessions: 1, + first_session: 123456789, + last_session: expect.any(Number) + } +}; + +const mockRequest = new MockAdapter(baseAxiosRequest); + +describe('validateCustomEventUserUpdateAPI', () => { + beforeAll(() => { + (global as any).localStorage = localStorageMock; + global.window = Object.create({ location: { hostname: 'google.com' } }); + mockRequest.onGet(GETMESSAGES_PATH).reply(200, { + data: 'something' + }); + mockRequest.onPost('/events/track').reply(200, {}); + mockRequest.onPost('/users/update').reply(200, {}); + mockRequest.onPost(ENDPOINT_MERGE_USER).reply(200, {}); + mockRequest.onGet(GET_CRITERIA_PATH).reply(200, {}); + mockRequest.onPost(ENDPOINT_TRACK_ANON_SESSION).reply(200, {}); + }); + + beforeEach(() => { + mockRequest.reset(); + mockRequest.resetHistory(); + mockRequest.onGet(GETMESSAGES_PATH).reply(200, { + data: 'something' + }); + mockRequest.onPost('/events/track').reply(200, {}); + mockRequest.onPost('/users/update').reply(200, {}); + mockRequest.onPost(ENDPOINT_MERGE_USER).reply(200, {}); + mockRequest.onGet(GET_CRITERIA_PATH).reply(200, {}); + mockRequest.onPost(ENDPOINT_TRACK_ANON_SESSION).reply(200, {}); + jest.resetAllMocks(); + jest.useFakeTimers(); + }); + + it('should not have unnecessary extra nesting when locally stored user update fields are sent to server', async () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + ...userDataMatched.dataFields, + eventType: userDataMatched.eventType + } + ]); + } + if (key === SHARED_PREFS_CRITERIA) { + return JSON.stringify(USER_UPDATE_API_TEST_CRITERIA); + } + if (key === SHARED_PREFS_ANON_SESSIONS) { + return JSON.stringify(initialAnonSessionInfo); + } + if (key === SHARED_PREF_ANON_USAGE_TRACKED) { + return 'true'; + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const localStoredCriteriaSets = localStorage.getItem(SHARED_PREFS_CRITERIA); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + + const result = checker.getMatchedCriteria(localStoredCriteriaSets!); + expect(result).toEqual('6'); + + const { setUserID, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { enableAnonActivation: true } + }); + logout(); // logout to remove logged in users before this test + + try { + await updateUser(); + } catch (e) { + console.log(''); + } + expect(localStorage.setItem).toHaveBeenCalledWith( + SHARED_PREFS_EVENT_LIST_KEY, + expect.any(String) + ); + await setUserID('testuser123'); + + const trackEvents = mockRequest.history.post.filter( + (req) => req.url === '/users/update' + ); + + expect(trackEvents.length > 0).toBe(true); + + trackEvents.forEach((req) => { + const requestData = JSON.parse(String(req?.data)); + + expect(requestData).toHaveProperty( + 'dataFields', + userDataMatched.dataFields + ); + expect(requestData.dataFields).toHaveProperty( + 'furniture', + userDataMatched.dataFields.furniture + ); + expect(requestData.dataFields).toHaveProperty( + 'furniture.furnitureType', + userDataMatched.dataFields.furniture.furnitureType + ); + expect(requestData.dataFields).toHaveProperty( + 'furniture.furnitureColor', + userDataMatched.dataFields.furniture.furnitureColor + ); + + expect(requestData).not.toHaveProperty('furniture'); + expect(requestData).not.toHaveProperty('furnitureType'); + expect(requestData).not.toHaveProperty('furnitureColor'); + expect(requestData).not.toHaveProperty('furniture.furnitureType'); + expect(requestData).not.toHaveProperty('furniture.furnitureColor'); + }); + }); + + it('should not have unnecessary extra nesting when locally stored user update fields are sent to server - Fail', async () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([ + { + ...userData, + ...userData.dataFields, + eventType: userData.eventType + } + ]); + } + if (key === SHARED_PREFS_CRITERIA) { + return JSON.stringify(USER_UPDATE_API_TEST_CRITERIA); + } + if (key === SHARED_PREFS_ANON_SESSIONS) { + return JSON.stringify(initialAnonSessionInfo); + } + if (key === SHARED_PREF_ANON_USAGE_TRACKED) { + return 'true'; + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const localStoredCriteriaSets = localStorage.getItem(SHARED_PREFS_CRITERIA); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + + const result = checker.getMatchedCriteria(localStoredCriteriaSets!); + expect(result).toEqual('6'); + + const { setUserID, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { enableAnonActivation: true } + }); + logout(); // logout to remove logged in users before this test + + try { + await updateUser(); + } catch (e) { + console.log(''); + } + expect(localStorage.setItem).toHaveBeenCalledWith( + SHARED_PREFS_EVENT_LIST_KEY, + expect.any(String) + ); + await setUserID('testuser123'); + + const trackEvents = mockRequest.history.post.filter( + (req) => req.url === '/users/update' + ); + + expect(trackEvents.length > 0).toBe(true); + + trackEvents.forEach((req) => { + const requestData = JSON.parse(String(req?.data)); + + expect(requestData).toHaveProperty('dataFields'); + expect(requestData.dataFields).toHaveProperty('furniture'); + expect(requestData.dataFields).toHaveProperty('furniture.furnitureType'); + expect(requestData.dataFields).toHaveProperty('furniture.furnitureColor'); + + expect(requestData).not.toHaveProperty('furniture'); + expect(requestData).not.toHaveProperty('furniture.furnitureType'); + expect(requestData).not.toHaveProperty('furniture.furnitureColor'); + expect(requestData.dataFields).toHaveProperty('furnitureType'); + expect(requestData.dataFields).toHaveProperty('furnitureColor'); + }); + }); + + it('should not have unnecessary extra nesting when locally stored custom event fields are sent to server', async () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([eventDataMatched]); + } + if (key === SHARED_PREFS_CRITERIA) { + return JSON.stringify(CUSTOM_EVENT_API_TEST_CRITERIA); + } + if (key === SHARED_PREFS_ANON_SESSIONS) { + return JSON.stringify(initialAnonSessionInfo); + } + if (key === SHARED_PREF_ANON_USAGE_TRACKED) { + return 'true'; + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const localStoredCriteriaSets = localStorage.getItem(SHARED_PREFS_CRITERIA); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + + const result = checker.getMatchedCriteria(localStoredCriteriaSets!); + + expect(result).toEqual('6'); + + const { setUserID, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { enableAnonActivation: true } + }); + logout(); // logout to remove logged in users before this test + + try { + await track(eventDataMatched); + } catch (e) { + console.log(''); + } + expect(localStorage.setItem).toHaveBeenCalledWith( + SHARED_PREFS_EVENT_LIST_KEY, + expect.any(String) + ); + await setUserID('testuser123'); + + const trackEvents = mockRequest.history.post.filter( + (req) => req.url === '/events/track' + ); + + trackEvents.forEach((req) => { + const requestData = JSON.parse(String(req?.data)); + + expect(requestData).toHaveProperty( + 'eventName', + eventDataMatched.eventName + ); + expect(requestData).toHaveProperty( + 'dataFields', + eventDataMatched.dataFields + ); + + expect(requestData).not.toHaveProperty(eventDataMatched.eventName); + expect(requestData).not.toHaveProperty('type'); + expect(requestData).not.toHaveProperty('count'); + expect(requestData).not.toHaveProperty('vaccinated'); + expect(requestData).not.toHaveProperty('animal-found.type'); + expect(requestData).not.toHaveProperty('animal-found.count'); + expect(requestData).not.toHaveProperty('animal-found.vaccinated'); + }); + }); + + it('should not have unnecessary extra nesting when locally stored custom event fields are sent to server - Fail', async () => { + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREFS_EVENT_LIST_KEY) { + return JSON.stringify([eventData]); + } + if (key === SHARED_PREFS_CRITERIA) { + return JSON.stringify(CUSTOM_EVENT_API_TEST_CRITERIA); + } + if (key === SHARED_PREFS_ANON_SESSIONS) { + return JSON.stringify(initialAnonSessionInfo); + } + if (key === SHARED_PREF_ANON_USAGE_TRACKED) { + return 'true'; + } + return null; + }); + + const localStoredEventList = localStorage.getItem( + SHARED_PREFS_EVENT_LIST_KEY + ); + + const localStoredCriteriaSets = localStorage.getItem(SHARED_PREFS_CRITERIA); + + const checker = new CriteriaCompletionChecker( + localStoredEventList === null ? '' : localStoredEventList + ); + const result = checker.getMatchedCriteria( + JSON.stringify(localStoredCriteriaSets) + ); + expect(result).toBeNull(); + + const { setUserID, logout } = initializeWithConfig({ + authToken: '123', + configOptions: { enableAnonActivation: true } + }); + logout(); // logout to remove logged in users before this test + + try { + await track(eventData); + } catch (e) { + console.log(''); + } + expect(localStorage.setItem).toHaveBeenCalledWith( + SHARED_PREFS_EVENT_LIST_KEY, + expect.any(String) + ); + await setUserID('testuser123'); + + const trackEvents = mockRequest.history.post.filter( + (req) => req.url === '/events/track' + ); + + trackEvents.forEach((req) => { + const requestData = JSON.parse(String(req?.data)); + + expect(requestData).toHaveProperty('eventName', eventData.eventName); + expect(requestData).toHaveProperty('dataFields', eventData.dataFields); + + expect(requestData).not.toHaveProperty(eventData.eventName); + expect(requestData).toHaveProperty('type'); + expect(requestData).toHaveProperty('count'); + expect(requestData).toHaveProperty('vaccinated'); + expect(requestData).not.toHaveProperty('animal-found.type'); + expect(requestData).not.toHaveProperty('animal-found.count'); + expect(requestData).not.toHaveProperty('animal-found.vaccinated'); + }); + }); +}); diff --git a/src/authorization/authorization.test.ts b/src/authorization/authorization.test.ts index 84614faa..54428994 100644 --- a/src/authorization/authorization.test.ts +++ b/src/authorization/authorization.test.ts @@ -1,19 +1,25 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { initialize } from './authorization'; +import { initialize, setTypeOfAuthForTestingOnly } from './authorization'; import { baseAxiosRequest } from '../request'; import { getInAppMessages } from '../inapp'; import { track, trackInAppClose } from '../events'; import { updateSubscriptions, updateUser, updateUserEmail } from '../users'; import { trackPurchase, updateCart } from '../commerce'; -import { GETMESSAGES_PATH } from '../constants'; - -let mockRequest: any = null; +import { + GETMESSAGES_PATH, + INITIALIZE_ERROR, + SHARED_PREF_USER_TOKEN +} from '../constants'; const localStorageMock = { - setItem: jest.fn() + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn() }; +let mockRequest: any = null; + /* decoded payload is: @@ -29,6 +35,7 @@ const MOCK_JWT_KEY_WITH_ONE_MINUTE_EXPIRY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJleHAiOjE2Nzk0ODMyOTEsImlhdCI6MTY3OTQ4MzIzMX0.APaQAYy-lTE0o8rbR6b6-28eCICq36SQMBXmeZAvk1k'; describe('API Key Interceptors', () => { beforeAll(() => { + (global as any).localStorage = localStorageMock; mockRequest = new MockAdapter(baseAxiosRequest); mockRequest.onGet(GETMESSAGES_PATH).reply(200, { data: 'something' @@ -38,6 +45,8 @@ describe('API Key Interceptors', () => { }); beforeEach(() => { + setTypeOfAuthForTestingOnly('userID'); + mockRequest.onPost('/users/update').reply(200, { data: 'something' }); @@ -97,7 +106,12 @@ describe('API Key Interceptors', () => { const { setEmail } = initialize('123', () => Promise.resolve(MOCK_JWT_KEY) ); - (global as any).localStorage = localStorageMock; + (localStorage.getItem as jest.Mock).mockImplementation((key) => { + if (key === SHARED_PREF_USER_TOKEN) { + return MOCK_JWT_KEY; + } + return null; + }); await setEmail('hello@gmail.com'); const response = await getInAppMessages({ @@ -105,7 +119,7 @@ describe('API Key Interceptors', () => { packageName: 'my-lil-website' }); expect(response.config.headers['Api-Key']).toBe('123'); - expect(response.config.headers['Authorization']).toBe( + expect(response.config.headers.Authorization).toBe( `Bearer ${MOCK_JWT_KEY}` ); }); @@ -122,7 +136,7 @@ describe('API Key Interceptors', () => { packageName: 'my-lil-website' }); expect(response.config.headers['Api-Key']).toBe('123'); - expect(response.config.headers['Authorization']).toBe( + expect(response.config.headers.Authorization).toBe( `Bearer ${MOCK_JWT_KEY}` ); }); @@ -227,8 +241,8 @@ describe('API Key Interceptors', () => { await updateUserEmail('helloworld@gmail.com'); jest.advanceTimersByTime(60000 * 4.1); - /* - called once originally, a second time after the email was changed, + /* + called once originally, a second time after the email was changed, and a third after the JWT was about to expire */ expect(mockGenerateJWT).toHaveBeenCalledTimes(3); @@ -276,8 +290,8 @@ describe('API Key Interceptors', () => { }); jest.advanceTimersByTime(60000 * 4.1); - /* - called once originally, a second time after the email was changed, + /* + called once originally, a second time after the email was changed, and a third after the JWT was about to expire */ expect(mockGenerateJWT).toHaveBeenCalledTimes(3); @@ -310,8 +324,8 @@ describe('API Key Interceptors', () => { }); jest.advanceTimersByTime(60000 * 4.1); - /* - called once originally, a second time after the email was changed, + /* + called once originally, a second time after the email was changed, and a third after the JWT was about to expire */ expect(mockGenerateJWT).toHaveBeenCalledTimes(3); @@ -339,6 +353,8 @@ describe('API Key Interceptors', () => { describe('User Identification', () => { beforeEach(() => { + setTypeOfAuthForTestingOnly('userID'); + /* clear any interceptors already configured */ [ ...Array( @@ -351,6 +367,7 @@ describe('User Identification', () => { describe('non-JWT auth', () => { beforeAll(() => { + (global as any).localStorage = localStorageMock; mockRequest = new MockAdapter(baseAxiosRequest); mockRequest.onPost('/users/update').reply(200, {}); @@ -363,14 +380,17 @@ describe('User Identification', () => { describe('logout', () => { it('logout method removes the email field from requests', async () => { const { logout, setEmail } = initialize('123'); - setEmail('hello@gmail.com'); + await setEmail('hello@gmail.com'); logout(); - const response = await getInAppMessages({ - count: 10, - packageName: 'my-lil-website' - }); - expect(response.config.params.email).toBeUndefined(); + try { + await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + } catch (e) { + expect(e).toStrictEqual(INITIALIZE_ERROR); + } }); it('logout method removes the userId field from requests', async () => { @@ -379,32 +399,34 @@ describe('User Identification', () => { await setUserID('hello@gmail.com'); logout(); - const response = await getInAppMessages({ - count: 10, - packageName: 'my-lil-website' - }); - expect(response.config.params.userId).toBeUndefined(); + try { + await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + } catch (e) { + expect(e).toStrictEqual(INITIALIZE_ERROR); + } }); }); describe('setEmail', () => { it('adds email param to endpoint that need an email as a param', async () => { const { setEmail } = initialize('123'); - setEmail('hello@gmail.com'); + await setEmail('hello@gmail.com'); const response = await getInAppMessages({ count: 10, packageName: 'my-lil-website' }); - expect(response.config.params.email).toBe('hello@gmail.com'); }); it('clears any previous interceptors if called twice', async () => { const spy = jest.spyOn(baseAxiosRequest.interceptors.request, 'eject'); const { setEmail } = initialize('123'); - setEmail('hello@gmail.com'); - setEmail('new@gmail.com'); + await setEmail('hello@gmail.com'); + await setEmail('new@gmail.com'); const response = await getInAppMessages({ count: 10, @@ -421,7 +443,7 @@ describe('User Identification', () => { it('adds email body to endpoint that need an email as a body', async () => { const { setEmail } = initialize('123'); - setEmail('hello@gmail.com'); + await setEmail('hello@gmail.com'); mockRequest.onPost('/events/trackInAppClose').reply(200, { data: 'something' @@ -450,7 +472,7 @@ describe('User Identification', () => { expect(JSON.parse(subsResponse.config.data).email).toBe( 'hello@gmail.com' ); - expect(JSON.parse(userResponse.config.data).email).toBe( + expect(JSON.parse(userResponse && userResponse.config.data).email).toBe( 'hello@gmail.com' ); expect(JSON.parse(trackResponse.config.data).email).toBe( @@ -460,7 +482,7 @@ describe('User Identification', () => { it('adds currentEmail body to endpoint that need an currentEmail as a body', async () => { const { setEmail } = initialize('123'); - setEmail('hello@gmail.com'); + await setEmail('hello@gmail.com'); mockRequest.onPost('/users/updateEmail').reply(200, { data: 'something' @@ -475,7 +497,7 @@ describe('User Identification', () => { it('should add user.email param to endpoints that need it', async () => { const { setEmail } = initialize('123'); - setEmail('hello@gmail.com'); + await setEmail('hello@gmail.com'); mockRequest.onPost('/commerce/updateCart').reply(200, { data: 'something' @@ -496,7 +518,7 @@ describe('User Identification', () => { it('adds no email body or header information to unrelated endpoints', async () => { const { setEmail } = initialize('123'); - setEmail('hello@gmail.com'); + await setEmail('hello@gmail.com'); mockRequest.onPost('/users/hello').reply(200, { data: 'something' @@ -518,7 +540,7 @@ describe('User Identification', () => { it('should overwrite user ID set by setUserID', async () => { const { setEmail, setUserID } = initialize('123'); await setUserID('999'); - setEmail('hello@gmail.com'); + await setEmail('hello@gmail.com'); const response = await getInAppMessages({ count: 10, @@ -601,7 +623,9 @@ describe('User Identification', () => { expect(JSON.parse(closeResponse.config.data).userId).toBe('999'); expect(JSON.parse(subsResponse.config.data).userId).toBe('999'); - expect(JSON.parse(userResponse.config.data).userId).toBe('999'); + expect( + JSON.parse(userResponse && userResponse.config.data).userId + ).toBe('999'); expect(JSON.parse(trackResponse.config.data).userId).toBe('999'); }); @@ -657,7 +681,7 @@ describe('User Identification', () => { it('should overwrite email set by setEmail', async () => { const { setEmail, setUserID } = initialize('123'); - setEmail('hello@gmail.com'); + await setEmail('hello@gmail.com'); await setUserID('999'); const response = await getInAppMessages({ @@ -667,19 +691,6 @@ describe('User Identification', () => { expect(response.config.params.email).toBeUndefined(); expect(response.config.params.userId).toBe('999'); }); - - it('should try /users/update 0 times if request to create a user fails', async () => { - mockRequest.onPost('/users/update').reply(400, {}); - - const { setUserID } = initialize('123'); - await setUserID('999'); - - expect( - mockRequest.history.post.filter( - (e: any) => !!e.url?.match(/users\/update/gim) - ).length - ).toBe(1); - }); }); }); @@ -702,11 +713,14 @@ describe('User Identification', () => { await setEmail('hello@gmail.com'); logout(); - const response = await getInAppMessages({ - count: 10, - packageName: 'my-lil-website' - }); - expect(response.config.params.email).toBeUndefined(); + try { + await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + } catch (e) { + expect(e).toStrictEqual(INITIALIZE_ERROR); + } }); it('logout method removes the userId field from requests', async () => { @@ -716,11 +730,14 @@ describe('User Identification', () => { await setUserID('hello@gmail.com'); logout(); - const response = await getInAppMessages({ - count: 10, - packageName: 'my-lil-website' - }); - expect(response.config.params.userId).toBeUndefined(); + try { + await getInAppMessages({ + count: 10, + packageName: 'my-lil-website' + }); + } catch (e) { + expect(e).toStrictEqual(INITIALIZE_ERROR); + } }); }); @@ -793,7 +810,7 @@ describe('User Identification', () => { expect(JSON.parse(subsResponse.config.data).email).toBe( 'hello@gmail.com' ); - expect(JSON.parse(userResponse.config.data).email).toBe( + expect(JSON.parse(userResponse && userResponse.config.data).email).toBe( 'hello@gmail.com' ); expect(JSON.parse(trackResponse.config.data).email).toBe( @@ -960,7 +977,9 @@ describe('User Identification', () => { expect(JSON.parse(closeResponse.config.data).userId).toBe('999'); expect(JSON.parse(subsResponse.config.data).userId).toBe('999'); - expect(JSON.parse(userResponse.config.data).userId).toBe('999'); + expect( + JSON.parse(userResponse && userResponse.config.data).userId + ).toBe('999'); expect(JSON.parse(trackResponse.config.data).userId).toBe('999'); }); @@ -1069,7 +1088,7 @@ describe('User Identification', () => { packageName: 'my-lil-website' }); expect(response.config.headers['Api-Key']).toBe('123'); - expect(response.config.headers['Authorization']).toBe( + expect(response.config.headers.Authorization).toBe( `Bearer ${MOCK_JWT_KEY}` ); }); @@ -1086,7 +1105,7 @@ describe('User Identification', () => { packageName: 'my-lil-website' }); expect(response.config.headers['Api-Key']).toBe('123'); - expect(response.config.headers['Authorization']).toBe( + expect(response.config.headers.Authorization).toBe( `Bearer ${MOCK_JWT_KEY}` ); }); @@ -1100,7 +1119,6 @@ describe('User Identification', () => { .mockReturnValue(Promise.resolve(MOCK_JWT_KEY)); const { refreshJwtToken } = initialize('123', mockGenerateJWT); await refreshJwtToken('hello@gmail.com'); - expect(mockGenerateJWT).toHaveBeenCalledTimes(1); jest.advanceTimersByTime(60000 * 4.1); expect(mockGenerateJWT).toHaveBeenCalledTimes(2); diff --git a/src/authorization/authorization.ts b/src/authorization/authorization.ts index 109bf78a..c579a977 100644 --- a/src/authorization/authorization.ts +++ b/src/authorization/authorization.ts @@ -1,17 +1,16 @@ /* eslint-disable */ import axios from 'axios'; import { baseAxiosRequest } from '../request'; -import { updateUser } from '../users'; -import { clearMessages } from '../inapp'; +import { clearMessages } from 'src/inapp/inapp'; import { IS_PRODUCTION, - RETRY_USER_ATTEMPTS, STATIC_HEADERS, - SHARED_PREF_USER_ID, - SHARED_PREF_EMAIL, + SHARED_PREF_ANON_USER_ID, ENDPOINTS, - RouteConfig -} from '../constants'; + RouteConfig, + SHARED_PREF_ANON_USAGE_TRACKED, + SHARED_PREFS_CRITERIA +} from 'src/constants'; import { cancelAxiosRequestAndMakeFetch, getEpochDifferenceInMS, @@ -21,39 +20,300 @@ import { validateTokenTime, isEmail } from './utils'; -import { Options, config } from '../utils/config'; +import { AnonymousUserMerge } from 'src/anonymousUserTracking/anonymousUserMerge'; +import { + AnonymousUserEventManager, + isAnonymousUsageTracked, + registerAnonUserIdSetter +} from 'src/anonymousUserTracking/anonymousUserEventManager'; +import { IdentityResolution, Options, config } from 'src/utils/config'; +import { getTypeOfAuth, setTypeOfAuth, TypeOfAuth } from 'src/utils/typeOfAuth'; +import AuthorizationToken from 'src/utils/authorizationToken'; const MAX_TIMEOUT = ONE_DAY; +let authIdentifier: null | string = null; +let userInterceptor: number | null = null; +let apiKey: null | string = null; +let generateJWTGlobal: any = null; +const anonUserManager = new AnonymousUserEventManager(); export interface GenerateJWTPayload { email?: string; userID?: string; } +const doesRequestUrlContain = (routeConfig: RouteConfig) => + Object.entries(ENDPOINTS).some( + (entry) => + routeConfig.route === entry[1].route && + routeConfig.body === entry[1].body && + routeConfig.current === entry[1].current && + routeConfig.nestedUser === entry[1].nestedUser + ); export interface WithJWT { clearRefresh: () => void; - setEmail: (email: string) => Promise; - setUserID: (userId: string) => Promise; + setEmail: (email: string, identityResolution?: IdentityResolution) => Promise; + setUserID: (userId: string, identityResolution?: IdentityResolution) => Promise; logout: () => void; refreshJwtToken: (authTypes: string) => Promise; + setVisitorUsageTracked: (consent: boolean) => void; + clearVisitorEventsAndUserData: () => void; } export interface WithoutJWT { setNewAuthToken: (newToken?: string) => void; clearAuthToken: () => void; - setEmail: (email: string) => void; - setUserID: (userId: string) => Promise; + setEmail: (email: string, identityResolution?: IdentityResolution) => Promise; + setUserID: (userId: string, identityResolution?: IdentityResolution) => Promise; logout: () => void; + setVisitorUsageTracked: (consent: boolean) => void; + clearVisitorEventsAndUserData: () => void; } -const doesRequestUrlContain = (routeConfig: RouteConfig) => - Object.entries(ENDPOINTS).some( - (entry) => - routeConfig.route === entry[1].route && - routeConfig.body === entry[1].body && - routeConfig.current === entry[1].current && - routeConfig.nestedUser === entry[1].nestedUser - ); +export const setAnonUserId = async (userId: string) => { + const anonymousUsageTracked = isAnonymousUsageTracked(); + + if (!anonymousUsageTracked) return; + + let token: null | string = null; + if (generateJWTGlobal) { + token = await generateJWTGlobal({ userID: userId }); + } + + if (token) { + const authorizationToken = new AuthorizationToken(); + authorizationToken.setToken(token); + } + + baseAxiosRequest.interceptors.request.use((config) => { + config.headers.set('Api-Key', apiKey); + + return config; + }); + addUserIdToRequest(userId); + localStorage.setItem(SHARED_PREF_ANON_USER_ID, userId); +}; + +registerAnonUserIdSetter(setAnonUserId); + +const clearAnonymousUser = () => { + localStorage.removeItem(SHARED_PREF_ANON_USER_ID); +}; + +const getAnonUserId = () => { + if (config.getConfig('enableAnonActivation')) { + const anonUser = localStorage.getItem(SHARED_PREF_ANON_USER_ID); + return anonUser === undefined ? null : anonUser; + } else { + return null; + } +}; + +const initializeUserId = (userId: string) => { + addUserIdToRequest(userId); + clearAnonymousUser(); +}; + +const addUserIdToRequest = (userId: string) => { + setTypeOfAuth('userID'); + authIdentifier = userId; + + if (typeof userInterceptor === 'number') { + baseAxiosRequest.interceptors.request.eject(userInterceptor); + } + /* + endpoints that use _userId_ payload prop in POST/PUT requests + */ + userInterceptor = baseAxiosRequest.interceptors.request.use((config) => { + if ( + doesRequestUrlContain({ + route: config?.url ?? '', + body: true, + current: true, + nestedUser: true + }) + ) { + return { + ...config, + data: { + ...(config.data || {}), + currentUserId: userId + } + }; + } + + /* + endpoints that use _userId_ payload prop in POST/PUT requests + */ + if ( + doesRequestUrlContain({ + route: config?.url ?? '', + body: true, + current: false, + nestedUser: false + }) + ) { + return { + ...config, + data: { + ...(config.data || {}), + userId + } + }; + } + + /* + endpoints that use _userId_ payload prop in POST/PUT requests nested in { user: {} } + */ + if ( + doesRequestUrlContain({ + route: config?.url ?? '', + body: true, + current: false, + nestedUser: true + }) + ) { + return { + ...config, + data: { + ...(config.data || {}), + user: { + ...(config.data.user || {}), + userId + } + } + }; + } + + /* + endpoints that use _userId_ query param in GET requests + */ + if ( + doesRequestUrlContain({ + route: config?.url ?? '', + body: false, + current: false, + nestedUser: false + }) + ) { + return { + ...config, + params: { + ...(config.params || {}), + userId + } + }; + } + + return config; + }); +}; + +const initializeEmailUser = (email: string) => { + addEmailToRequest(email); + clearAnonymousUser(); +}; + +const syncEvents = () => { + if (config.getConfig('enableAnonActivation')) { + anonUserManager.syncEvents(); + } +}; + +const addEmailToRequest = (email: string) => { + setTypeOfAuth('email'); + authIdentifier = email; + + if (typeof userInterceptor === 'number') { + baseAxiosRequest.interceptors.request.eject(userInterceptor); + } + userInterceptor = baseAxiosRequest.interceptors.request.use((config) => { + /* + endpoints that use _currentEmail_ payload prop in POST/PUT requests + */ + if ( + doesRequestUrlContain({ + route: config?.url ?? '', + body: true, + current: true, + nestedUser: true + }) + ) { + return { + ...config, + data: { + ...(config.data || {}), + currentEmail: email + } + }; + } + + /* + endpoints that use _email_ payload prop in POST/PUT requests + */ + if ( + doesRequestUrlContain({ + route: config?.url ?? '', + body: true, + current: false, + nestedUser: false + }) + ) { + return { + ...config, + data: { + ...(config.data || {}), + email + } + }; + } + + /* + endpoints that use _userId_ payload prop in POST/PUT requests nested in { user: {} } + */ + if ( + doesRequestUrlContain({ + route: config?.url ?? '', + body: true, + current: false, + nestedUser: true + }) + ) { + return { + ...config, + data: { + ...(config.data || {}), + user: { + ...(config.data.user || {}), + email + } + } + }; + } + + /* + endpoints that use _email_ query param in GET requests + */ + if ( + doesRequestUrlContain({ + route: config?.url ?? '', + body: false, + current: false, + nestedUser: false + }) + ) { + return { + ...config, + params: { + ...(config.params || {}), + email + } + }; + } + + return config; + }); +}; export function initialize( authToken: string, @@ -64,6 +324,8 @@ export function initialize( authToken: string, generateJWT?: (payload: GenerateJWTPayload) => Promise ) { + apiKey = authToken; + generateJWTGlobal = generateJWT; const logLevel = config.getConfig('logLevel'); if (!generateJWT && IS_PRODUCTION) { /* only let people use non-JWT mode if running the app locally */ @@ -74,36 +336,17 @@ export function initialize( } return; } - - /* + /* only set token interceptor if we're using a non-JWT key. Otherwise, we'll set it later once we generate the JWT */ - let authInterceptor: number | null = generateJWT - ? null - : baseAxiosRequest.interceptors.request.use((config) => { - config.headers.set('Api-Key', authToken); + let authInterceptor: number | null = + baseAxiosRequest.interceptors.request.use((config) => { + config.headers.set('Api-Key', authToken); - return config; - }); - let userInterceptor: number | null = null; + return config; + }); let responseInterceptor: number | null = null; - /* - AKA did the user auth with their email (setEmail) or user ID (setUserID) - - we're going to use this variable for one circumstance - when calling _updateUserEmail_. - Essentially, when we call the Iterable API to update a user's email address and we get a - successful 200 request, we're going to request a new JWT token, since it might need to - be re-signed with the new email address; however, if the customer code never authorized the - user with an email and instead a user ID, we'll just continue to sign the JWT with the user ID. - - This is mainly just a quality-of-life feature, so that the customer's JWT generation code - doesn't _need_ to support email-signed JWTs if they don't want and purely want to issue the - tokens by user ID. - */ - let typeOfAuth: null | 'email' | 'userID' = null; - /* this will be the literal user ID or email they choose to auth with */ - let authIdentifier: null | string = null; /** method that sets a timer one minute before JWT expiration @@ -153,97 +396,58 @@ export function initialize( const handleTokenExpiration = createTokenExpirationTimer(); - const addEmailToRequest = (email: string) => { - userInterceptor = baseAxiosRequest.interceptors.request.use((config) => { - /* - endpoints that use _currentEmail_ payload prop in POST/PUT requests - */ - if ( - doesRequestUrlContain({ - route: config?.url ?? '', - body: true, - current: true, - nestedUser: true - }) - ) { - return { - ...config, - data: { - ...(config.data || {}), - currentEmail: email - } - }; - } - - /* - endpoints that use _email_ payload prop in POST/PUT requests - */ - if ( - doesRequestUrlContain({ - route: config?.url ?? '', - body: true, - current: false, - nestedUser: false - }) - ) { - return { - ...config, - data: { - ...(config.data || {}), - email - } - }; - } - - /* - endpoints that use _userId_ payload prop in POST/PUT requests nested in { user: {} } - */ - if ( - doesRequestUrlContain({ - route: config?.url ?? '', - body: true, - current: false, - nestedUser: true - }) - ) { - return { - ...config, - data: { - ...(config.data || {}), - user: { - ...(config.data.user || {}), - email - } - } - }; + const enableAnonymousTracking = () => { + try { + if (config.getConfig('enableAnonActivation')) { + anonUserManager.getAnonCriteria(); + anonUserManager.updateAnonSession(); + const anonymousUserId = getAnonUserId(); + if (anonymousUserId !== null) { + // This block will restore the anon userID from localstorage + setAnonUserId(anonymousUserId); + } } + } catch (error) { + console.warn(error); + } + }; - /* - endpoints that use _email_ query param in GET requests - */ - - if ( - doesRequestUrlContain({ - route: config?.url ?? '', - body: false, - current: false, - nestedUser: false - }) - ) { - return { - ...config, - params: { - ...(config.params || {}), - email - } - }; + const tryMergeUser = async ( + emailOrUserId: string, + isEmail: boolean, + merge?: boolean + ): Promise => { + const typeOfAuth = getTypeOfAuth(); + const enableAnonActivation = config.getConfig('enableAnonActivation'); + const sourceUserIdOrEmail = + authIdentifier === null ? getAnonUserId() : authIdentifier; + const sourceUserId = typeOfAuth === 'email' ? null : sourceUserIdOrEmail; + const sourceEmail = typeOfAuth === 'email' ? sourceUserIdOrEmail : null; + const destinationUserId = isEmail ? null : emailOrUserId; + const destinationEmail = isEmail ? emailOrUserId : null; + // This function will try to merge if anon user exists + if ( + (getAnonUserId() !== null || authIdentifier !== null) && + merge && + enableAnonActivation + ) { + const anonymousUserMerge = new AnonymousUserMerge(); + try { + await anonymousUserMerge.mergeUser( + sourceUserId, + sourceEmail, + destinationUserId, + destinationEmail + ); + } catch (error) { + return Promise.reject(`merging failed: ${error}`); } - - return config; - }); + } + return Promise.resolve(true); // promise resolves here because merging is not needed so we setUserID passed via dev }; if (!generateJWT) { + enableAnonymousTracking(); /* we want to set a normal non-JWT enabled API key */ return { setNewAuthToken: (newToken: string) => { @@ -265,151 +469,76 @@ export function initialize( baseAxiosRequest.interceptors.request.eject(authInterceptor); } }, - setEmail: (email: string) => { - typeOfAuth = 'email'; - authIdentifier = email; - localStorage.setItem(SHARED_PREF_EMAIL, email); + setEmail: async ( + email: string, + identityResolution?: IdentityResolution + ) => { clearMessages(); - if (typeof userInterceptor === 'number') { - baseAxiosRequest.interceptors.request.eject(userInterceptor); + try { + const identityResolutionConfig = + config.getConfig('identityResolution'); + const merge = + identityResolution?.mergeOnAnonymousToKnown || + identityResolutionConfig?.mergeOnAnonymousToKnown; + const replay = + identityResolution?.replayOnVisitorToKnown || + identityResolutionConfig?.replayOnVisitorToKnown; + + const result = await tryMergeUser(email, true, merge); + if (result) { + initializeEmailUser(email); + if (replay) { + syncEvents(); + } else { + anonUserManager.removeAnonSessionCriteriaData(); + } + return Promise.resolve(); + } + } catch (error) { + // here we will not sync events but just bubble up error of merge + return Promise.reject(`merging failed: ${error}`); } - - /* - endpoints that use _currentEmail_ payload prop in POST/PUT requests - */ - addEmailToRequest(email); }, - setUserID: async (userId: string) => { - typeOfAuth = 'userID'; - authIdentifier = userId; - localStorage.setItem(SHARED_PREF_USER_ID, userId); + setUserID: async ( + userId: string, + identityResolution?: IdentityResolution + ) => { clearMessages(); - - if (typeof userInterceptor === 'number') { - baseAxiosRequest.interceptors.request.eject(userInterceptor); - } - - /* - endpoints that use _currentUserId payload prop in POST/PUT requests nested in { user: {} } - */ - userInterceptor = baseAxiosRequest.interceptors.request.use( - (config) => { - if ( - doesRequestUrlContain({ - route: config?.url ?? '', - body: true, - current: true, - nestedUser: true - }) - ) { - return { - ...config, - data: { - ...(config.data || {}), - currentUserId: userId - } - }; - } - - /* - endpoints that use _userId_ payload prop in POST/PUT requests - */ - if ( - doesRequestUrlContain({ - route: config?.url ?? '', - body: true, - current: false, - nestedUser: false - }) - ) { - return { - ...config, - data: { - ...(config.data || {}), - userId - } - }; - } - - /* - endpoints that use _userId_ payload prop in POST/PUT requests nested in { user: {} } - */ - if ( - doesRequestUrlContain({ - route: config?.url ?? '', - body: true, - current: false, - nestedUser: true - }) - ) { - return { - ...config, - data: { - ...(config.data || {}), - user: { - ...(config.data.user || {}), - userId - } - } - }; - } - - /* - endpoints that use _userId_ query param in GET requests - */ - if ( - doesRequestUrlContain({ - route: config?.url ?? '', - body: false, - current: false, - nestedUser: false - }) - ) { - return { - ...config, - params: { - ...(config.params || {}), - userId - } - }; + try { + const identityResolutionConfig = + config.getConfig('identityResolution'); + const merge = + identityResolution?.mergeOnAnonymousToKnown || + identityResolutionConfig?.mergeOnAnonymousToKnown; + const replay = + identityResolution?.replayOnVisitorToKnown || + identityResolutionConfig?.replayOnVisitorToKnown; + + const result = await tryMergeUser(userId, false, merge); + if (result) { + initializeUserId(userId); + if (replay) { + syncEvents(); + } else { + anonUserManager.removeAnonSessionCriteriaData(); } - - return config; + return Promise.resolve(); } - ); - - const tryUser = () => { - let createUserAttempts = 0; - - return async function tryUserNTimes(): Promise { - try { - return await updateUser({}); - } catch (e) { - if (createUserAttempts < RETRY_USER_ATTEMPTS) { - createUserAttempts += 1; - return tryUserNTimes(); - } - - return Promise.reject( - `could not create user after ${createUserAttempts} tries` - ); - } - }; - }; - - try { - return await tryUser()(); - } catch (e) { - /* failed to create a new user. Just silently resolve */ - return Promise.resolve(); + } catch (error) { + // here we will not sync events but just bubble up error of merge + return Promise.reject(`merging failed: ${error}`); } }, logout: () => { - typeOfAuth = null; + anonUserManager.removeAnonSessionCriteriaData(); + setTypeOfAuth(null); authIdentifier = null; /* clear fetched in-app messages */ clearMessages(); + const authorizationToken = new AuthorizationToken(); + authorizationToken.clearToken(); + if (typeof authInterceptor === 'number') { /* stop adding auth token to requests */ baseAxiosRequest.interceptors.request.eject(authInterceptor); @@ -419,15 +548,48 @@ export function initialize( /* stop adding JWT to requests */ baseAxiosRequest.interceptors.request.eject(userInterceptor); } + }, + setVisitorUsageTracked: (consent: boolean) => { + /* if consent is true, we want to clear anon user data and start tracking from point forward */ + if (consent) { + anonUserManager.removeAnonSessionCriteriaData(); + localStorage.removeItem(SHARED_PREFS_CRITERIA); + + localStorage.setItem(SHARED_PREF_ANON_USAGE_TRACKED, 'true'); + enableAnonymousTracking(); + } else { + /* if consent is false, we want to stop tracking and clear anon user data */ + const anonymousUsageTracked = isAnonymousUsageTracked(); + if (anonymousUsageTracked) { + anonUserManager.removeAnonSessionCriteriaData(); + + localStorage.removeItem(SHARED_PREFS_CRITERIA); + localStorage.removeItem(SHARED_PREF_ANON_USER_ID); + localStorage.removeItem(SHARED_PREF_ANON_USAGE_TRACKED); + + setTypeOfAuth(null); + authIdentifier = null; + /* clear fetched in-app messages */ + clearMessages(); + } + localStorage.setItem(SHARED_PREF_ANON_USAGE_TRACKED, 'false'); + } + }, + clearVisitorEventsAndUserData: () => { + anonUserManager.removeAnonSessionCriteriaData(); + clearAnonymousUser(); } }; } + const authorizationToken = new AuthorizationToken(); /* We're using a JWT enabled API key callback is assumed to be some sort of GET /api/generate-jwt */ const doRequest = (payload: { email?: string; userID?: string }) => { + authorizationToken.clearToken(); + /* clear any token interceptor if any exists */ if (typeof authInterceptor === 'number') { baseAxiosRequest.interceptors.request.eject(authInterceptor); @@ -439,6 +601,9 @@ export function initialize( return generateJWT(payload) .then((token) => { + const authorizationToken = new AuthorizationToken(); + authorizationToken.setToken(token); + /* set JWT token and auth token headers */ authInterceptor = baseAxiosRequest.interceptors.request.use( (config) => { @@ -467,7 +632,6 @@ export function initialize( } config.headers.set('Api-Key', authToken); - config.headers.set('Authorization', `Bearer ${token}`); return config; } @@ -493,11 +657,14 @@ export function initialize( const newEmail = JSON.parse(config.config.data)?.newEmail; const payloadToPass = - typeOfAuth === 'email' + getTypeOfAuth() === 'email' ? { email: newEmail } : { userID: authIdentifier! }; return generateJWT(payloadToPass).then((newToken) => { + const authorizationToken = new AuthorizationToken(); + authorizationToken.setToken(newToken); + /* clear any existing interceptors that are adding user info or API keys @@ -543,7 +710,6 @@ export function initialize( } config.headers.set('Api-Key', authToken); - config.headers.set('Authorization', `Bearer ${newToken}`); return config; } @@ -590,6 +756,9 @@ export function initialize( if (error?.response?.status === 401) { return generateJWT(payload) .then((newToken) => { + const authorizationToken = new AuthorizationToken(); + authorizationToken.setToken(newToken); + if (authInterceptor) { baseAxiosRequest.interceptors.request.eject( authInterceptor @@ -621,7 +790,6 @@ export function initialize( } config.headers.set('Api-Key', authToken); - config.headers.set('Authorization', `Bearer ${newToken}`); return config; } @@ -666,172 +834,118 @@ export function initialize( ); return token; }) - .catch((error: any) => { + .catch((error) => { /* clear interceptor */ + const authorizationToken = new AuthorizationToken(); + authorizationToken.clearToken(); + if (typeof authInterceptor === 'number') { baseAxiosRequest.interceptors.request.eject(authInterceptor); } return Promise.reject(error); }); }; + + enableAnonymousTracking(); return { clearRefresh: () => { /* this will just clear the existing timeout */ handleTokenExpiration(''); }, - setEmail: (email: string) => { - typeOfAuth = 'email'; - authIdentifier = email; - localStorage.setItem(SHARED_PREF_EMAIL, email); + setEmail: async ( + email: string, + identityResolution?: IdentityResolution + ) => { /* clear previous user */ clearMessages(); - if (typeof userInterceptor === 'number') { - baseAxiosRequest.interceptors.request.eject(userInterceptor); - } + try { + const identityResolutionConfig = config.getConfig('identityResolution'); + const merge = + identityResolution?.mergeOnAnonymousToKnown || + identityResolutionConfig?.mergeOnAnonymousToKnown; + const replay = + identityResolution?.replayOnVisitorToKnown || + identityResolutionConfig?.replayOnVisitorToKnown; - addEmailToRequest(email); - - return doRequest({ email }).catch((e: any) => { - if (logLevel === 'verbose') { - console.warn( - 'Could not generate JWT after calling setEmail. Please try calling setEmail again.' - ); + try { + return doRequest({ email }) + .then(async (token) => { + const result = await tryMergeUser(email, true, merge); + if (result) { + initializeEmailUser(email); + if (replay) { + syncEvents(); + } else { + anonUserManager.removeAnonSessionCriteriaData(); + } + return token; + } + }) + .catch((e) => { + if (logLevel === 'verbose') { + console.warn( + 'Could not generate JWT after calling setEmail. Please try calling setEmail again.' + ); + } + return Promise.reject(e); + }); + } catch (e) { + /* failed to create a new user. Just silently resolve */ + return Promise.resolve(); } - return Promise.reject(e); - }); + } catch (error) { + // here we will not sync events but just bubble up error of merge + return Promise.reject(`merging failed: ${error}`); + } }, - setUserID: async (userId: string) => { - typeOfAuth = 'userID'; - authIdentifier = userId; - localStorage.setItem(SHARED_PREF_USER_ID, userId); + setUserID: async ( + userId: string, + identityResolution?: IdentityResolution + ) => { clearMessages(); + try { + const identityResolutionConfig = config.getConfig('identityResolution'); + const merge = + identityResolution?.mergeOnAnonymousToKnown || + identityResolutionConfig?.mergeOnAnonymousToKnown; + const replay = + identityResolution?.replayOnVisitorToKnown || + identityResolutionConfig?.replayOnVisitorToKnown; - if (typeof userInterceptor === 'number') { - baseAxiosRequest.interceptors.request.eject(userInterceptor); - } - - /* - endpoints that use _currentUserId_ payload prop in POST/PUT requests nested in user object - */ - userInterceptor = baseAxiosRequest.interceptors.request.use((config) => { - if ( - doesRequestUrlContain({ - route: config?.url ?? '', - body: true, - current: true, - nestedUser: true - }) - ) { - return { - ...config, - data: { - ...(config.data || {}), - currentUserId: userId - } - }; - } - - /* - endpoints that use _serId_ payload prop in POST/PUT requests - */ - if ( - doesRequestUrlContain({ - route: config?.url ?? '', - body: true, - current: false, - nestedUser: false - }) - ) { - return { - ...config, - data: { - ...(config.data || {}), - userId - } - }; - } - - /* - endpoints that use _userId_ payload prop in POST/PUT requests nested in { user: {} } - */ - if ( - doesRequestUrlContain({ - route: config?.url ?? '', - body: true, - current: false, - nestedUser: true - }) - ) { - return { - ...config, - data: { - ...(config.data || {}), - user: { - ...(config.data.user || {}), - userId + try { + return doRequest({ userID: userId }) + .then(async (token) => { + const result = await tryMergeUser(userId, false, merge); + if (result) { + initializeUserId(userId); + if (replay) { + syncEvents(); + } else { + anonUserManager.removeAnonSessionCriteriaData(); + } + return token; } - } - }; - } - - /* - endpoints that use _userId_ query param in GET requests - */ - if ( - doesRequestUrlContain({ - route: config?.url ?? '', - body: false, - current: false, - nestedUser: false - }) - ) { - return { - ...config, - params: { - ...(config.params || {}), - userId - } - }; + }) + .catch((e) => { + if (logLevel === 'verbose') { + console.warn( + 'Could not generate JWT after calling setUserID. Please try calling setUserID again.' + ); + } + return Promise.reject(e); + }); + } catch (e) { + /* failed to create a new user. Just silently resolve */ + return Promise.resolve(); } - - return config; - }); - - const tryUser = () => { - let createUserAttempts = 0; - - return async function tryUserNTimes(): Promise { - try { - return await updateUser({}); - } catch (e) { - if (createUserAttempts < RETRY_USER_ATTEMPTS) { - createUserAttempts += 1; - return tryUserNTimes(); - } - - return Promise.reject( - `could not create user after ${createUserAttempts} tries` - ); - } - }; - }; - - return doRequest({ userID: userId }) - .then(async (token) => { - await tryUser()(); - return token; - }) - .catch((e: any) => { - if (logLevel === 'verbose') { - console.warn( - 'Could not generate JWT after calling setUserID. Please try calling setUserID again.' - ); - } - return Promise.reject(e); - }); + } catch (error) { + // here we will not sync events but just bubble up error of merge + return Promise.reject(`merging failed: ${error}`); + } }, logout: () => { - typeOfAuth = null; + anonUserManager.removeAnonSessionCriteriaData(); + setTypeOfAuth(null); authIdentifier = null; /* clear fetched in-app messages */ clearMessages(); @@ -839,6 +953,9 @@ export function initialize( /* this will just clear the existing timeout */ handleTokenExpiration(''); + const authorizationToken = new AuthorizationToken(); + authorizationToken.clearToken(); + if (typeof authInterceptor === 'number') { /* stop adding auth token to requests */ baseAxiosRequest.interceptors.request.eject(authInterceptor); @@ -853,12 +970,42 @@ export function initialize( /* this will just clear the existing timeout */ handleTokenExpiration(''); const payloadToPass = { [isEmail(user) ? 'email' : 'userID']: user }; - return doRequest(payloadToPass).catch((e: any) => { + return doRequest(payloadToPass).catch((e) => { if (logLevel === 'verbose') { console.warn(e); console.warn('Could not refresh JWT. Try Refresh the JWT again.'); } }); + }, + setVisitorUsageTracked: (consent: boolean) => { + /* if consent is true, we want to clear anon user data and start tracking from point forward */ + if (consent) { + anonUserManager.removeAnonSessionCriteriaData(); + localStorage.removeItem(SHARED_PREFS_CRITERIA); + + localStorage.setItem(SHARED_PREF_ANON_USAGE_TRACKED, 'true'); + enableAnonymousTracking(); + } else { + /* if consent is false, we want to stop tracking and clear anon user data */ + const anonymousUsageTracked = isAnonymousUsageTracked(); + if (anonymousUsageTracked) { + anonUserManager.removeAnonSessionCriteriaData(); + + localStorage.removeItem(SHARED_PREFS_CRITERIA); + localStorage.removeItem(SHARED_PREF_ANON_USER_ID); + localStorage.removeItem(SHARED_PREF_ANON_USAGE_TRACKED); + + setTypeOfAuth(null); + authIdentifier = null; + /* clear fetched in-app messages */ + clearMessages(); + } + localStorage.setItem(SHARED_PREF_ANON_USAGE_TRACKED, 'false'); + } + }, + clearVisitorEventsAndUserData: () => { + anonUserManager.removeAnonSessionCriteriaData(); + clearAnonymousUser(); } }; } @@ -893,3 +1040,11 @@ export function initializeWithConfig(initializeParams: InitializeParams) { ? initialize(authToken, generateJWT) : initialize(authToken); } + +export function setTypeOfAuthForTestingOnly(authType: TypeOfAuth) { + if (!authType) { + setTypeOfAuth(null); + } else { + setTypeOfAuth(authType); + } +} diff --git a/src/commerce/commerce.test.ts b/src/commerce/commerce.test.ts index 24645400..2de26ac6 100644 --- a/src/commerce/commerce.test.ts +++ b/src/commerce/commerce.test.ts @@ -3,10 +3,21 @@ import { baseAxiosRequest } from '../request'; import { trackPurchase, updateCart } from './commerce'; // import { SDK_VERSION, WEB_PLATFORM } from '../constants'; import { createClientError } from '../utils/testUtils'; +import { setTypeOfAuthForTestingOnly } from '../authorization'; const mockRequest = new MockAdapter(baseAxiosRequest); +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn() +}; + describe('Users Requests', () => { + beforeEach(() => { + (global as any).localStorage = localStorageMock; + setTypeOfAuthForTestingOnly('email'); + }); it('should set params and return the correct payload for updateCart', async () => { mockRequest.onPost('/commerce/updateCart').reply(200, { msg: 'hello' diff --git a/src/commerce/commerce.ts b/src/commerce/commerce.ts index de8a6957..ec135bc7 100644 --- a/src/commerce/commerce.ts +++ b/src/commerce/commerce.ts @@ -1,9 +1,11 @@ /* eslint-disable no-param-reassign */ -import { ENDPOINTS } from '../constants'; +import { ENDPOINTS, AUA_WARNING } from '../constants'; import { baseIterableRequest } from '../request'; import { TrackPurchaseRequestParams, UpdateCartRequestParams } from './types'; import { IterableResponse } from '../types'; import { updateCartSchema, trackPurchaseSchema } from './commerce.schema'; +import { AnonymousUserEventManager } from '../anonymousUserTracking/anonymousUserEventManager'; +import { canTrackAnonUser } from '../utils/commonFunctions'; export const updateCart = (payload: UpdateCartRequestParams) => { /* a customer could potentially send these up if they're not using TypeScript */ @@ -11,6 +13,11 @@ export const updateCart = (payload: UpdateCartRequestParams) => { delete (payload as any).user.userId; delete (payload as any).user.email; } + if (canTrackAnonUser()) { + const anonymousUserEventManager = new AnonymousUserEventManager(); + anonymousUserEventManager.trackAnonUpdateCart(payload); + return Promise.reject(AUA_WARNING); + } return baseIterableRequest({ method: 'POST', @@ -34,6 +41,11 @@ export const trackPurchase = (payload: TrackPurchaseRequestParams) => { delete (payload as any).user.userId; delete (payload as any).user.email; } + if (canTrackAnonUser()) { + const anonymousUserEventManager = new AnonymousUserEventManager(); + anonymousUserEventManager.trackAnonPurchaseEvent(payload); + return Promise.reject(AUA_WARNING); + } return baseIterableRequest({ method: 'POST', diff --git a/src/constants.ts b/src/constants.ts index 59646f0a..b68fa9f3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,6 +4,9 @@ export const DISPLAY_INTERVAL_DEFAULT = 30000; /* how many times we try to create a new user when _setUserID_ is invoked */ export const RETRY_USER_ATTEMPTS = 0; +/* How many events can be stored in the local storage */ +export const DEFAULT_EVENT_THRESHOLD_LIMIT = 100; + const IS_EU_ITERABLE_SERVICE = process.env.IS_EU_ITERABLE_SERVICE === 'true'; export const dangerouslyAllowJsPopupExecution = @@ -23,6 +26,9 @@ export const EU_ITERABLE_API = `https://${EU_ITERABLE_DOMAIN}/api`; export const BASE_URL = process.env.BASE_URL || ITERABLE_API_URL; export const GETMESSAGES_PATH = '/inApp/web/getMessages'; +export const GET_CRITERIA_PATH = '/anonymoususer/list'; +export const ENDPOINT_MERGE_USER = '/users/merge'; +export const ENDPOINT_TRACK_ANON_SESSION = '/anonymoususer/events/session'; const GET_ENABLE_INAPP_CONSUME = () => { try { @@ -273,3 +279,37 @@ export const ANIMATION_STYLESHEET = ( transition: visibility 0s ${animationDuration}ms, opacity ${animationDuration}ms linear; } `; + +export const SHARED_PREFS_EVENT_TYPE = 'eventType'; +export const SHARED_PREFS_EVENT_LIST_KEY = 'itbl_event_list'; +export const SHARED_PREFS_CRITERIA = 'criteria'; +export const SHARED_PREFS_ANON_SESSIONS = 'itbl_anon_sessions'; +export const SHARED_PREF_ANON_USER_ID = 'anon_userId'; +export const SHARED_PREF_ANON_USAGE_TRACKED = 'itbl_anonymous_usage_tracked'; +export const SHARED_PREF_USER_TOKEN = 'itbl_auth_token'; + +export const KEY_EVENT_NAME = 'eventName'; +export const KEY_CREATED_AT = 'createdAt'; +export const KEY_DATA_FIELDS = 'dataFields'; +export const KEY_CREATE_NEW_FIELDS = 'createNewFields'; +export const KEY_ITEMS = 'items'; +export const KEY_TOTAL = 'total'; +export const KEY_PREFER_USERID = 'preferUserId'; +export const DATA_REPLACE = 'dataReplace'; + +export const TRACK_EVENT = 'customEvent'; +export const TRACK_PURCHASE = 'purchase'; +export const UPDATE_USER = 'user'; +export const TRACK_UPDATE_CART = 'cartUpdate'; +export const UPDATE_CART = 'updateCart'; + +export const PURCHASE_ITEM = 'shoppingCartItems'; +export const UPDATECART_ITEM_PREFIX = 'updateCart.updatedShoppingCartItems.'; +export const PURCHASE_ITEM_PREFIX = `${PURCHASE_ITEM}.`; + +export const INITIALIZE_ERROR = new Error( + 'Iterable SDK must be initialized with an API key and user email/userId before calling SDK methods' +); +export const AUA_WARNING = new Error( + 'This event was stored locally because you have Anonymous User Activation enabled. If this was unintentional, please check your SDK configuration settings.' +); diff --git a/src/embedded/embeddedManager.test.ts b/src/embedded/embeddedManager.test.ts index 904f1d2d..724a5890 100644 --- a/src/embedded/embeddedManager.test.ts +++ b/src/embedded/embeddedManager.test.ts @@ -1,4 +1,5 @@ import { IterableEmbeddedManager } from './embeddedManager'; +import { setTypeOfAuthForTestingOnly } from '../authorization'; // Mock the baseIterableRequest function jest.mock('../request', () => ({ @@ -11,6 +12,9 @@ jest.mock('..', () => ({ })); describe('EmbeddedManager', () => { + beforeEach(() => { + setTypeOfAuthForTestingOnly('email'); + }); const appPackageName = 'my-website'; describe('syncMessages', () => { it('should call syncMessages and callback', async () => { @@ -28,7 +32,8 @@ describe('EmbeddedManager', () => { const embeddedManager = new IterableEmbeddedManager(appPackageName); async function mockTest() { - return new Promise(function (resolve, reject) { + return new Promise((resolve, reject) => { + // eslint-disable-next-line prefer-promise-reject-errors reject('Invalid API Key'); }); } diff --git a/src/events/events.test.ts b/src/events/events.test.ts index 7c3eac95..ea552160 100644 --- a/src/events/events.test.ts +++ b/src/events/events.test.ts @@ -13,8 +13,9 @@ import { trackInAppDelivery, trackInAppOpen } from './inapp/events'; -import { WEB_PLATFORM } from '../constants'; +import { INITIALIZE_ERROR, WEB_PLATFORM } from '../constants'; import { createClientError } from '../utils/testUtils'; +import { setTypeOfAuthForTestingOnly } from '../authorization'; const mockRequest = new MockAdapter(baseAxiosRequest); const localStorageMock = { @@ -60,6 +61,11 @@ describe('Events Requests', () => { }); }); + beforeEach(() => { + (global as any).localStorage = localStorageMock; + setTypeOfAuthForTestingOnly('userID'); + }); + it('return the correct payload for track', async () => { const response = await track({ eventName: 'test' }); @@ -258,6 +264,54 @@ describe('Events Requests', () => { ); } }); + it('should fail trackInAppOpen if not authed', async () => { + try { + setTypeOfAuthForTestingOnly(null); + await trackInAppOpen({} as any); + } catch (e) { + expect(e).toBe(INITIALIZE_ERROR); + } + }); + it('should fail trackInAppClose if not authed', async () => { + try { + setTypeOfAuthForTestingOnly(null); + await trackInAppClose({} as any); + } catch (e) { + expect(e).toBe(INITIALIZE_ERROR); + } + }); + it('should fail trackInAppClick if not authed', async () => { + try { + setTypeOfAuthForTestingOnly(null); + await trackInAppClick({} as any); + } catch (e) { + expect(e).toBe(INITIALIZE_ERROR); + } + }); + it('should fail trackInAppConsume if not authed', async () => { + try { + setTypeOfAuthForTestingOnly(null); + await trackInAppConsume({} as any); + } catch (e) { + expect(e).toBe(INITIALIZE_ERROR); + } + }); + it('should fail trackInAppDelivery if not authed', async () => { + try { + setTypeOfAuthForTestingOnly(null); + await trackInAppDelivery({} as any); + } catch (e) { + expect(e).toBe(INITIALIZE_ERROR); + } + }); + it('should fail trackInAppOpen if not authed', async () => { + try { + setTypeOfAuthForTestingOnly(null); + await trackInAppOpen({} as any); + } catch (e) { + expect(e).toBe(INITIALIZE_ERROR); + } + }); it('return the correct payload for embedded message received', async () => { const response = await trackEmbeddedReceived('abc123', 'packageName'); @@ -477,4 +531,31 @@ describe('Events Requests', () => { expect(JSON.parse(trackSessionResponse.config.data).email).toBeUndefined(); expect(JSON.parse(trackSessionResponse.config.data).userId).toBeUndefined(); }); + + it('should fail if no auth type set for embedded received', async () => { + setTypeOfAuthForTestingOnly(null); + try { + await trackEmbeddedReceived('abc123', 'packageName'); + } catch (e) { + expect(e).toBe(INITIALIZE_ERROR); + } + }); + + it('should fail if no auth type set for embedded click', async () => { + setTypeOfAuthForTestingOnly(null); + try { + await trackEmbeddedClick({} as any); + } catch (e) { + expect(e).toBe(INITIALIZE_ERROR); + } + }); + + it('should fail if no auth type set for embedded session', async () => { + setTypeOfAuthForTestingOnly(null); + try { + await trackEmbeddedSession({} as any); + } catch (e) { + expect(e).toBe(INITIALIZE_ERROR); + } + }); }); diff --git a/src/events/events.ts b/src/events/events.ts index 34f65075..6bae9314 100644 --- a/src/events/events.ts +++ b/src/events/events.ts @@ -1,15 +1,21 @@ /* eslint-disable no-param-reassign */ -import { ENDPOINTS } from '../constants'; +import { ENDPOINTS, AUA_WARNING } from '../constants'; import { baseIterableRequest } from '../request'; import { InAppTrackRequestParams } from './inapp/types'; import { IterableResponse } from '../types'; import { trackSchema } from './events.schema'; +import { AnonymousUserEventManager } from '../anonymousUserTracking/anonymousUserEventManager'; +import { canTrackAnonUser } from '../utils/commonFunctions'; export const track = (payload: InAppTrackRequestParams) => { /* a customer could potentially send these up if they're not using TypeScript */ delete (payload as any).userId; delete (payload as any).email; - + if (canTrackAnonUser()) { + const anonymousUserEventManager = new AnonymousUserEventManager(); + anonymousUserEventManager.trackAnonEvent(payload); + return Promise.reject(AUA_WARNING); + } return baseIterableRequest({ method: 'POST', url: ENDPOINTS.event_track.route, diff --git a/src/inapp/inapp.ts b/src/inapp/inapp.ts index 4265ae34..692ecae3 100644 --- a/src/inapp/inapp.ts +++ b/src/inapp/inapp.ts @@ -17,7 +17,7 @@ import { trackInAppClose, trackInAppConsume, trackInAppOpen -} from '../events'; +} from '../events/inapp/events'; import { IterablePromise } from '../types'; import { requestMessages } from './request'; import { diff --git a/src/inapp/tests/inapp.test.ts b/src/inapp/tests/inapp.test.ts index 5c40c7bc..86793a72 100644 --- a/src/inapp/tests/inapp.test.ts +++ b/src/inapp/tests/inapp.test.ts @@ -3,7 +3,7 @@ */ import MockAdapter from 'axios-mock-adapter'; import { messages } from '../../__data__/inAppMessages'; -import { initialize } from '../../authorization'; +import { initialize, setTypeOfAuthForTestingOnly } from '../../authorization'; import { GETMESSAGES_PATH, SDK_VERSION, WEB_PLATFORM } from '../../constants'; import { baseAxiosRequest } from '../../request'; import { createClientError } from '../../utils/testUtils'; @@ -20,6 +20,7 @@ describe('getInAppMessages', () => { beforeEach(() => { jest.clearAllMocks(); jest.resetAllMocks(); + setTypeOfAuthForTestingOnly('email'); mockRequest.resetHistory(); mockRequest.onGet(GETMESSAGES_PATH).reply(200, { diff --git a/src/inapp/tests/utils.test.ts b/src/inapp/tests/utils.test.ts index c80633e9..00778d42 100644 --- a/src/inapp/tests/utils.test.ts +++ b/src/inapp/tests/utils.test.ts @@ -19,6 +19,7 @@ import { sortInAppMessages, trackMessagesDelivered } from '../utils'; +import { setTypeOfAuthForTestingOnly } from '../../authorization'; jest.mock('../../utils/srSpeak', () => ({ srSpeak: jest.fn() @@ -33,6 +34,9 @@ const mockMarkup = ` `; describe('Utils', () => { + beforeEach(() => { + setTypeOfAuthForTestingOnly('email'); + }); describe('filterHiddenInAppMessages', () => { it('should filter out read messages', () => { expect(filterHiddenInAppMessages()).toEqual([]); @@ -869,6 +873,7 @@ describe('Utils', () => { expect(el.getAttribute('aria-label')).toBe('hello'); expect(el.getAttribute('role')).toBe('button'); + // eslint-disable-next-line no-script-url expect(el.getAttribute('href')).toBe('javascript:undefined'); }); diff --git a/src/index.ts b/src/index.ts index e5169ad6..e6deee64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ export * from './embedded'; export * from './components/card'; export * from './components/banner'; export * from './components/notification'; -export { config } from './utils/config'; +export { config, IdentityResolution } from './utils/config'; export { IterableConfig } from './utils/IterableConfig'; export interface TextParentStyles { diff --git a/src/request.ts b/src/request.ts index 8badc433..6efa8cb8 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,9 +1,18 @@ import Axios, { AxiosRequestConfig } from 'axios'; import qs from 'qs'; import { AnySchema, ValidationError } from 'yup'; -import { BASE_URL, STATIC_HEADERS, EU_ITERABLE_API } from './constants'; +import { + BASE_URL, + STATIC_HEADERS, + EU_ITERABLE_API, + GET_CRITERIA_PATH, + INITIALIZE_ERROR, + ENDPOINT_TRACK_ANON_SESSION +} from './constants'; import { IterablePromise, IterableResponse } from './types'; import { config } from './utils/config'; +import { getTypeOfAuth } from './utils/typeOfAuth'; +import AuthorizationToken from './utils/authorizationToken'; interface ExtendedRequestConfig extends AxiosRequestConfig { validation?: { @@ -20,6 +29,11 @@ interface ClientError extends IterableResponse { }[]; } +const ENDPOINTS_NOT_REQUIRING_TYPE_OF_AUTH = [ + GET_CRITERIA_PATH, + ENDPOINT_TRACK_ANON_SESSION +]; + export const baseAxiosRequest = Axios.create({ baseURL: BASE_URL }); @@ -28,6 +42,15 @@ export const baseIterableRequest = ( payload: ExtendedRequestConfig ): IterablePromise => { try { + const endpoint = payload?.url ?? ''; + + // for most Iterable API endpoints, we require a user to be initialized in the SDK. + if ( + !ENDPOINTS_NOT_REQUIRING_TYPE_OF_AUTH.includes(endpoint) && + getTypeOfAuth() === null + ) { + return Promise.reject(INITIALIZE_ERROR); + } if (payload.validation?.data && payload.data) { payload.validation.data.validateSync(payload.data, { abortEarly: false }); } @@ -41,12 +64,17 @@ export const baseIterableRequest = ( ? EU_ITERABLE_API : config.getConfig('baseURL'); + const authorizationToken = new AuthorizationToken(); + const JWT = authorizationToken.getToken(); + const Authorization = JWT ? `Bearer ${JWT}` : undefined; + return baseAxiosRequest({ ...payload, baseURL, headers: { ...payload.headers, - ...STATIC_HEADERS + ...STATIC_HEADERS, + Authorization }, paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'repeat' }) diff --git a/src/users/types.ts b/src/users/types.ts index d0f7c5c8..5cafc406 100644 --- a/src/users/types.ts +++ b/src/users/types.ts @@ -12,10 +12,14 @@ export interface GetUserResponse { export interface UpdateUserParams { dataFields?: Record; - preferUserId?: boolean; mergeNestedObjects?: boolean; } +export interface UpdateAnonymousUserParams extends UpdateUserParams { + createNewFields?: boolean; + userId?: string; +} + export interface UpdateSubscriptionParams { emailListIds: number[]; unsubscribedChannelIds: number[]; diff --git a/src/users/users.schema.ts b/src/users/users.schema.ts index 91e0443f..4557a18a 100644 --- a/src/users/users.schema.ts +++ b/src/users/users.schema.ts @@ -1,6 +1,7 @@ -import { array, boolean, number, object } from 'yup'; +import { array, boolean, number, object, string } from 'yup'; export const updateUserSchema = object().shape({ + userId: string(), dataFields: object(), preferUserId: boolean(), mergeNestedObjects: boolean() diff --git a/src/users/users.test.ts b/src/users/users.test.ts index 3ec297c9..940b2b3b 100644 --- a/src/users/users.test.ts +++ b/src/users/users.test.ts @@ -2,11 +2,22 @@ import MockAdapter from 'axios-mock-adapter'; import { baseAxiosRequest } from '../request'; import { updateSubscriptions, updateUser, updateUserEmail } from './users'; import { createClientError } from '../utils/testUtils'; +import { setTypeOfAuthForTestingOnly } from '../authorization'; // import { SDK_VERSION, WEB_PLATFORM } from '../constants'; const mockRequest = new MockAdapter(baseAxiosRequest); +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn() +}; + describe('Users Requests', () => { + beforeEach(() => { + (global as any).localStorage = localStorageMock; + setTypeOfAuthForTestingOnly('email'); + }); it('should set params and return the correct payload for updateUser', async () => { mockRequest.onPost('/users/update').reply(200, { msg: 'hello' @@ -16,11 +27,13 @@ describe('Users Requests', () => { dataFields: {} }); - expect(JSON.parse(response.config.data).dataFields).toEqual({}); - expect(JSON.parse(response.config.data).preferUserId).toBe(true); + expect(JSON.parse(response && response.config.data).dataFields).toEqual({}); + expect(JSON.parse(response && response.config.data).preferUserId).toBe( + true + ); // expect(response.config.headers['SDK-Version']).toBe(SDK_VERSION); // expect(response.config.headers['SDK-Platform']).toBe(WEB_PLATFORM); - expect(response.data.msg).toBe('hello'); + expect(response && response.data.msg).toBe('hello'); }); it('should reject updateUser on bad params', async () => { diff --git a/src/users/users.ts b/src/users/users.ts index 279a56a9..e8f4cc34 100644 --- a/src/users/users.ts +++ b/src/users/users.ts @@ -1,10 +1,12 @@ // eslint-disable @typescript-eslint/no-explicit-any import { object, string } from 'yup'; -import { ENDPOINTS } from '../constants'; import { IterableResponse } from '../types'; import { baseIterableRequest } from '../request'; import { UpdateSubscriptionParams, UpdateUserParams } from './types'; import { updateSubscriptionsSchema, updateUserSchema } from './users.schema'; +import { AnonymousUserEventManager } from '../anonymousUserTracking/anonymousUserEventManager'; +import { canTrackAnonUser } from '../utils/commonFunctions'; +import { AUA_WARNING, ENDPOINTS } from '../constants'; export const updateUserEmail = (newEmail: string) => baseIterableRequest({ @@ -26,6 +28,11 @@ export const updateUser = (payloadParam: UpdateUserParams = {}) => { delete (payload as any).userId; delete (payload as any).email; + if (canTrackAnonUser()) { + const anonymousUserEventManager = new AnonymousUserEventManager(); + anonymousUserEventManager.trackAnonUpdateUser(payload); + return Promise.reject(AUA_WARNING); + } return baseIterableRequest({ method: 'POST', url: ENDPOINTS.users_update.route, diff --git a/src/utils/authorizationToken.ts b/src/utils/authorizationToken.ts new file mode 100644 index 00000000..e8fec965 --- /dev/null +++ b/src/utils/authorizationToken.ts @@ -0,0 +1,21 @@ +import { SHARED_PREF_USER_TOKEN } from '../constants'; + +class AuthorizationToken { + public token: string | null = null; + + setToken(token: string) { + this.token = token; + localStorage.setItem(SHARED_PREF_USER_TOKEN, token); + } + + getToken(): string | null { + return this.token || localStorage.getItem(SHARED_PREF_USER_TOKEN); + } + + clearToken() { + this.token = ''; + localStorage.removeItem(SHARED_PREF_USER_TOKEN); + } +} + +export default AuthorizationToken; diff --git a/src/utils/commonFunctions.ts b/src/utils/commonFunctions.ts new file mode 100644 index 00000000..46471516 --- /dev/null +++ b/src/utils/commonFunctions.ts @@ -0,0 +1,5 @@ +import config from './config'; +import { getTypeOfAuth } from './typeOfAuth'; + +export const canTrackAnonUser = (): boolean => + config.getConfig('enableAnonActivation') && getTypeOfAuth() === null; diff --git a/src/utils/config.ts b/src/utils/config.ts index 491314e9..42c74969 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,18 +1,33 @@ -import { BASE_URL } from '../constants'; +import { BASE_URL, DEFAULT_EVENT_THRESHOLD_LIMIT } from '../constants'; + +export type IdentityResolution = { + replayOnVisitorToKnown?: boolean; + mergeOnAnonymousToKnown?: boolean; +}; export type Options = { logLevel: 'none' | 'verbose'; baseURL: string; + enableAnonActivation: boolean; isEuIterableService: boolean; dangerouslyAllowJsPopups: boolean; + eventThresholdLimit?: number; + onAnonUserCreated?: (userId: string) => void; + identityResolution?: IdentityResolution; }; const _config = () => { let options: Options = { logLevel: 'none', baseURL: BASE_URL, + enableAnonActivation: false, isEuIterableService: false, - dangerouslyAllowJsPopups: false + dangerouslyAllowJsPopups: false, + eventThresholdLimit: DEFAULT_EVENT_THRESHOLD_LIMIT, + identityResolution: { + replayOnVisitorToKnown: true, + mergeOnAnonymousToKnown: true + } }; const getConfig = (option: K) => options[option]; @@ -22,7 +37,11 @@ const _config = () => { setConfig: (newOptions: Partial) => { options = { ...options, - ...newOptions + ...newOptions, + identityResolution: { + ...options.identityResolution, + ...newOptions.identityResolution + } }; } }; diff --git a/src/utils/typeOfAuth.ts b/src/utils/typeOfAuth.ts new file mode 100644 index 00000000..38092020 --- /dev/null +++ b/src/utils/typeOfAuth.ts @@ -0,0 +1,24 @@ +/* eslint-disable import/no-mutable-exports */ + +/* + AKA did the user auth with their email (setEmail) or user ID (setUserID) + + we're going to use this variable for one circumstance - when calling _updateUserEmail_. + Essentially, when we call the Iterable API to update a user's email address and we get a + successful 200 request, we're going to request a new JWT token, since it might need to + be re-signed with the new email address; however, if the customer code never authorized the + user with an email and instead a user ID, we'll just continue to sign the JWT with the user ID. + + This is mainly just a quality-of-life feature, so that the customer's JWT generation code + doesn't _need_ to support email-signed JWTs if they don't want and purely want to issue the + tokens by user ID. + */ +/* this will be the literal user ID or email they choose to auth with */ + +export type TypeOfAuth = null | 'email' | 'userID'; +let typeOfAuth: TypeOfAuth = null; +export const setTypeOfAuth = (value: TypeOfAuth) => { + typeOfAuth = value; +}; + +export const getTypeOfAuth = () => typeOfAuth; diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 00000000..656aadeb --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,21 @@ +import { UpdateAnonymousUserParams } from '..'; + +interface AnonSessionContext { + totalAnonSessionCount?: number; + lastAnonSession?: number; + firstAnonSession?: number; + webPushOptIn?: string; + lastPage?: string; + matchedCriteriaId: number; +} + +export interface TrackAnonSessionParams { + user: UpdateAnonymousUserParams; + createdAt: number; + deviceInfo: { + deviceId: string; + appPackageName: string; // customer-defined name + platform: string; + }; + anonSessionContext: AnonSessionContext; +} diff --git a/yarn.lock b/yarn.lock index 31e79372..28d76dfc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1902,7 +1902,7 @@ resolved "https://registry.yarnpkg.com/@types/throttle-debounce/-/throttle-debounce-2.1.0.tgz#1c3df624bfc4b62f992d3012b84c56d41eab3776" integrity sha512-5eQEtSCoESnh2FsiLTxE121IiE60hnMqcb435fShf4bpLRjEu1Eoekht23y6zXS9Ts3l+Szu3TARnTsA0GkOkQ== -"@types/uuid@^9.0.2": +"@types/uuid@^9.0.8": version "9.0.8" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba" integrity sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==