diff --git a/snippet.js b/snippet.js index d73640ed0..6ee2306ad 100644 --- a/snippet.js +++ b/snippet.js @@ -44,7 +44,7 @@ 'stopTrackingLocation', ]; var ecommerceMethods = ['setCurrencyCode', 'logCheckout']; - var identityMethods = ['identify', 'login', 'logout', 'modify']; + var identityMethods = ['identify', 'login', 'logout', 'modify', 'search']; var roktMethods = [ 'selectPlacements', 'hashAttributes', diff --git a/snippet.min.js b/snippet.min.js index 282452c38..2eb286665 100644 --- a/snippet.min.js +++ b/snippet.min.js @@ -1 +1 @@ -(function(e){window.mParticle=window.mParticle||{};window.mParticle.EventType={Unknown:0,Navigation:1,Location:2,Search:3,Transaction:4,UserContent:5,UserPreference:6,Social:7,Other:8,Media:9};window.mParticle.eCommerce={Cart:{}};window.mParticle.Identity={};window.mParticle.Rokt={};window.mParticle.config=window.mParticle.config||{};window.mParticle.config.rq=[];window.mParticle.config.snippetVersion=2.8;window.mParticle.ready=function(e){window.mParticle.config.rq.push(e)};var t=["endSession","logError","logBaseEvent","logEvent","logForm","logLink","logPageView","setSessionAttribute","setAppName","setAppVersion","setOptOut","setPosition","startNewSession","startTrackingLocation","stopTrackingLocation"];var i=["setCurrencyCode","logCheckout"];var n=["identify","login","logout","modify"];var o=["selectPlacements","hashAttributes","hashSha256","setExtensionData","use","getVersion","terminate"];t.forEach(function(e){window.mParticle[e]=r(e)});i.forEach(function(e){window.mParticle.eCommerce[e]=r(e,"eCommerce")});n.forEach(function(e){window.mParticle.Identity[e]=r(e,"Identity")});o.forEach(function(e){window.mParticle.Rokt[e]=r(e,"Rokt")});function r(t,i){return function(){if(i){t=i+"."+t}var e=Array.prototype.slice.call(arguments);e.unshift(t);window.mParticle.config.rq.push(e)}}var a,c,s=window.mParticle.config,l=s.isDevelopmentMode?1:0,w="?env="+l,d=window.mParticle.config.dataPlan;if(d){a=d.planId;c=d.planVersion;if(a){if(c&&(c<1||c>1e3)){c=null}w+="&plan_id="+a+(c?"&plan_version="+c:"")}}var m=window.mParticle.config.versions;var f=[];if(m){Object.keys(m).forEach(function(e){f.push(e+"="+m[e])})}var p=document.createElement("script");p.type="text/javascript";p.async=true;p.src=("https:"==document.location.protocol?"https://jssdkcdns":"http://jssdkcdn")+".mparticle.com/js/v2/"+e+"/mparticle.js"+w+"&"+f.join("&");var P=document.getElementsByTagName("script")[0];P.parentNode.insertBefore(p,P)})("REPLACE WITH API KEY"); \ No newline at end of file +(function(e){window.mParticle=window.mParticle||{};window.mParticle.EventType={Unknown:0,Navigation:1,Location:2,Search:3,Transaction:4,UserContent:5,UserPreference:6,Social:7,Other:8,Media:9};window.mParticle.eCommerce={Cart:{}};window.mParticle.Identity={};window.mParticle.Rokt={};window.mParticle.config=window.mParticle.config||{};window.mParticle.config.rq=[];window.mParticle.config.snippetVersion=2.8;window.mParticle.ready=function(e){window.mParticle.config.rq.push(e)};var t=["endSession","logError","logBaseEvent","logEvent","logForm","logLink","logPageView","setSessionAttribute","setAppName","setAppVersion","setOptOut","setPosition","startNewSession","startTrackingLocation","stopTrackingLocation"];var i=["setCurrencyCode","logCheckout"];var n=["identify","login","logout","modify","search"];var o=["selectPlacements","hashAttributes","hashSha256","setExtensionData","use","getVersion","terminate","onShoppableAdsReady"];t.forEach(function(e){window.mParticle[e]=r(e)});i.forEach(function(e){window.mParticle.eCommerce[e]=r(e,"eCommerce")});n.forEach(function(e){window.mParticle.Identity[e]=r(e,"Identity")});o.forEach(function(e){window.mParticle.Rokt[e]=r(e,"Rokt")});function r(t,i){return function(){if(i){t=i+"."+t}var e=Array.prototype.slice.call(arguments);e.unshift(t);window.mParticle.config.rq.push(e)}}var a,c,s=window.mParticle.config,l=s.isDevelopmentMode?1:0,d="?env="+l,w=window.mParticle.config.dataPlan;if(w){a=w.planId;c=w.planVersion;if(a){if(c&&(c<1||c>1e3)){c=null}d+="&plan_id="+a+(c?"&plan_version="+c:"")}}var m=window.mParticle.config.versions;var f=[];if(m){Object.keys(m).forEach(function(e){f.push(e+"="+m[e])})}var p=document.createElement("script");p.type="text/javascript";p.async=true;p.src=("https:"==document.location.protocol?"https://jssdkcdns":"http://jssdkcdn")+".mparticle.com/js/v2/"+e+"/mparticle.js"+d+"&"+f.join("&");var P=document.getElementsByTagName("script")[0];P.parentNode.insertBefore(p,P)})("REPLACE WITH API KEY"); \ No newline at end of file diff --git a/snippet.rokt.js b/snippet.rokt.js index e731253b2..ba6b4e854 100644 --- a/snippet.rokt.js +++ b/snippet.rokt.js @@ -44,7 +44,7 @@ 'stopTrackingLocation', ]; var ecommerceMethods = ['setCurrencyCode', 'logCheckout']; - var identityMethods = ['identify', 'login', 'logout', 'modify']; + var identityMethods = ['identify', 'login', 'logout', 'modify', 'search']; var roktMethods = [ 'selectPlacements', 'hashAttributes', diff --git a/snippet.rokt.min.js b/snippet.rokt.min.js index 5a534fee6..cdb462b1d 100644 --- a/snippet.rokt.min.js +++ b/snippet.rokt.min.js @@ -1 +1 @@ -(function(e){window.mParticle=window.mParticle||{};window.mParticle.EventType={Unknown:0,Navigation:1,Location:2,Search:3,Transaction:4,UserContent:5,UserPreference:6,Social:7,Other:8,Media:9};window.mParticle.eCommerce={Cart:{}};window.mParticle.Identity={};window.mParticle.Rokt={};window.mParticle.config=window.mParticle.config||{};window.mParticle.config.rq=[];window.mParticle.config.snippetVersion=2.8;window.mParticle.ready=function(e){window.mParticle.config.rq.push(e)};var i=["endSession","logError","logBaseEvent","logEvent","logForm","logLink","logPageView","setSessionAttribute","setAppName","setAppVersion","setOptOut","setPosition","startNewSession","startTrackingLocation","stopTrackingLocation"];var t=["setCurrencyCode","logCheckout"];var n=["identify","login","logout","modify"];var o=["selectPlacements","hashAttributes","hashSha256","setExtensionData","use","getVersion","terminate"];i.forEach(function(e){window.mParticle[e]=r(e)});t.forEach(function(e){window.mParticle.eCommerce[e]=r(e,"eCommerce")});n.forEach(function(e){window.mParticle.Identity[e]=r(e,"Identity")});o.forEach(function(e){window.mParticle.Rokt[e]=r(e,"Rokt")});function r(i,t){return function(){if(t){i=t+"."+i}var e=Array.prototype.slice.call(arguments);e.unshift(i);window.mParticle.config.rq.push(e)}}var a=window.mParticle.config,c=a.isDevelopmentMode?1:0,s="?env="+c,l=a.dataPlan;if(l){var w=l.planId,m=l.planVersion;if(w){if(m&&(m<1||m>1e3)){m=null}s+="&plan_id="+w+(m?"&plan_version="+m:"")}}var d=a.versions;var f=[];if(d){Object.keys(d).forEach(function(e){f.push(e+"="+d[e])})}var p=document.createElement("script");p.type="text/javascript";p.async=true;window.ROKT_DOMAIN=ROKT_DOMAIN||"https://apps.rokt-api.com";window.mParticle.config.domain=ROKT_DOMAIN.split("//")[1];p.src=ROKT_DOMAIN+"/js/v2/"+e+"/app.js"+s+"&"+f.join("&");var P=document.getElementsByTagName("script")[0];P.parentNode.insertBefore(p,P)})("REPLACE WITH API KEY"); \ No newline at end of file +(function(e){window.mParticle=window.mParticle||{};window.mParticle.EventType={Unknown:0,Navigation:1,Location:2,Search:3,Transaction:4,UserContent:5,UserPreference:6,Social:7,Other:8,Media:9};window.mParticle.eCommerce={Cart:{}};window.mParticle.Identity={};window.mParticle.Rokt={};window.mParticle.config=window.mParticle.config||{};window.mParticle.config.rq=[];window.mParticle.config.snippetVersion=2.8;window.mParticle.ready=function(e){window.mParticle.config.rq.push(e)};var i=["endSession","logError","logBaseEvent","logEvent","logForm","logLink","logPageView","setSessionAttribute","setAppName","setAppVersion","setOptOut","setPosition","startNewSession","startTrackingLocation","stopTrackingLocation"];var t=["setCurrencyCode","logCheckout"];var n=["identify","login","logout","modify","search"];var o=["selectPlacements","hashAttributes","hashSha256","setExtensionData","use","getVersion","terminate","onShoppableAdsReady"];i.forEach(function(e){window.mParticle[e]=r(e)});t.forEach(function(e){window.mParticle.eCommerce[e]=r(e,"eCommerce")});n.forEach(function(e){window.mParticle.Identity[e]=r(e,"Identity")});o.forEach(function(e){window.mParticle.Rokt[e]=r(e,"Rokt")});function r(i,t){return function(){if(t){i=t+"."+i}var e=Array.prototype.slice.call(arguments);e.unshift(i);window.mParticle.config.rq.push(e)}}var a=window.mParticle.config,c=a.isDevelopmentMode?1:0,s="?env="+c,l=a.dataPlan;if(l){var w=l.planId,d=l.planVersion;if(w){if(d&&(d<1||d>1e3)){d=null}s+="&plan_id="+w+(d?"&plan_version="+d:"")}}var m=a.versions;var p=[];if(m){Object.keys(m).forEach(function(e){p.push(e+"="+m[e])})}var f=document.createElement("script");f.type="text/javascript";f.async=true;window.ROKT_DOMAIN=ROKT_DOMAIN||"https://apps.rokt-api.com";window.mParticle.config.domain=ROKT_DOMAIN.split("//")[1];f.src=ROKT_DOMAIN+"/js/v2/"+e+"/app.js"+s+"&"+p.join("&");var P=document.getElementsByTagName("script")[0];P.parentNode.insertBefore(f,P)})("REPLACE WITH API KEY"); \ No newline at end of file diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 14ac5753e..6a84b5485 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -1,5 +1,5 @@ import Constants, { ONE_DAY_IN_SECONDS, MILLIS_IN_ONE_SEC } from './constants'; -import { Dictionary, parseNumber, isObject, generateHash, isEmpty } from './utils'; +import { Dictionary, Environment, parseNumber, isObject, generateHash, generateUniqueId, isEmpty, isFunction } from './utils'; import { BaseVault } from './vault'; import Types from './types'; import { @@ -14,8 +14,16 @@ import { IMParticleUser, } from './identity-user-interfaces'; import { IStore } from './store'; +import type { IMParticleWebSDKInstance } from './mp-instance'; +import { + IIdentitySearchKnownIdentities, + IIdentitySearchRequestBody, + IdentitySearchCallback, + sendSearchRequest, +} from './identity/search'; const { Identify, Modify, Login, Logout } = Constants.IdentityMethods; +const { HTTPCodes, Messages } = Constants; export const CACHE_HEADER = 'x-mp-max-age' as const; export type IdentityCache = BaseVault>; @@ -321,3 +329,76 @@ export const hasExplicitIdentifier = (store: IStore | undefined | null): boolean } return !!store?.SDKConfig?.deviceId; }; + +/** + * Builds the /v1/identify-style envelope (client_sdk, environment, + * request_id, request_timestamp_ms) used to correlate IDSync requests + * across endpoints. `known_identities` is omitted so the caller can + * fold in the search-specific identifiers alongside the envelope. + */ +export const buildIdentitySearchEnvelope = ( + environment: Environment, +): Omit => ({ + client_sdk: { + platform: Constants.platform, + sdk_vendor: Constants.sdkVendor, + sdk_version: Constants.sdkVersion, + }, + environment, + request_id: generateUniqueId(), + request_timestamp_ms: Date.now(), +}); + +/** + * Wires the SDK instance into `sendSearchRequest`: gates on `canLog`, + * builds the `/v1/search` URL and request envelope, and dispatches. + * Lives here so the SDK glue (URL building, opt-out gate, dispatcher + * plumbing) is type-checked instead of being expressed in plain JS. + */ +export const executeSearchRequest = ( + mpInstance: IMParticleWebSDKInstance, + workspaceApiKey: string, + knownIdentities: IIdentitySearchKnownIdentities, + callback: IdentitySearchCallback, +): void => { + const { _Helpers, _Store, Logger, _ErrorReportingDispatcher } = mpInstance; + const { identityUrl, isDevelopmentMode } = _Store.SDKConfig; + + if (!_Helpers.canLog()) { + Logger.verbose(Messages.InformationMessages.AbandonLogEvent); + if (isFunction(callback)) { + try { + callback({ + httpCode: HTTPCodes.loggingDisabledOrMissingAPIKey, + }); + } catch (e) { + Logger.error( + 'Error invoking search callback: ' + + ((e as Error)?.message || String(e)), + ); + } + } + return; + } + + // The Search endpoint is colocated with /v1/identify under + // identityUrl, so we reuse the same service URL builder. We do + // NOT append the apiKey to the URL — auth is done via x-mp-key. + const serviceUrl: string = _Helpers.createServiceUrl(identityUrl); + const searchUrl: string = serviceUrl + 'search?cb=1'; + + const environment: Environment = isDevelopmentMode + ? 'development' + : 'production'; + + sendSearchRequest( + knownIdentities, + workspaceApiKey, + () => buildIdentitySearchEnvelope(environment), + searchUrl, + callback, + Logger, + undefined, + _ErrorReportingDispatcher, + ); +}; diff --git a/src/identity.interfaces.ts b/src/identity.interfaces.ts index 32ad3ea6e..1a02400e6 100644 --- a/src/identity.interfaces.ts +++ b/src/identity.interfaces.ts @@ -12,6 +12,10 @@ import { mParticleUserCart, IIdentityResponse, } from './identity-user-interfaces'; +import { + IIdentitySearchKnownIdentities, + IdentitySearchCallback, +} from './identity/search'; const { platform, sdkVendor, sdkVersion, HTTPCodes } = Constants; export type IdentityPreProcessResult = { @@ -156,8 +160,31 @@ export interface SDKIdentityApi { destinationUser: IMParticleUser, scope?: AliasRequestScope ): IAliasRequest; + /** + * Sends a request to mParticle's IDSync `/v1/search` endpoint to look up + * a workspace identity without affecting the current user. The callback + * receives `httpCode` (always) and an optional `body` containing the + * parsed JSON response. Consumers should gate behaviour on + * `httpCode === 200`. + * + * `workspaceApiKey` is a workspace-specific API key supplied by the + * caller (from a kit's settings). It is sent as the `x-mp-key` header. + * The SDK's own workspace token is intentionally not used. + */ + search?( + workspaceApiKey: string, + knownIdentities: IIdentitySearchKnownIdentities, + callback: IdentitySearchCallback + ): void; } +export type { + IIdentitySearchKnownIdentities, + IIdentitySearchResult, + IIdentitySearchResponseBody, + IdentitySearchCallback, +} from './identity/search'; + export interface IIdentity { audienceManager: AudienceManager; idCache: BaseVault>; diff --git a/src/identity.js b/src/identity.js index 2a7bb3a67..65cebd111 100644 --- a/src/identity.js +++ b/src/identity.js @@ -3,6 +3,7 @@ import Types, { IdentityType } from './types'; import { cacheOrClearIdCache, createKnownIdentities, + executeSearchRequest, tryCacheIdentity, } from './identity-utils'; import AudienceManager from './audienceManager'; @@ -730,6 +731,32 @@ export default function Identity(mpInstance) { } }, + /** + * Search the IDSync Workspace endpoint for a known identity. + * + * POSTs to mParticle's `/v1/search` endpoint and invokes `callback` + * with `{ httpCode, body? }`. + * + * The `workspaceApiKey` is a workspace-specific API key supplied by + * the caller (passed in from a kit's settings). It is intentionally + * NOT read from the SDK's own workspace token, so that workspace + * searches can be authorised independently of the host SDK's + * workspace. + * + * @method search + * @param {String} workspaceApiKey Workspace API key (sent as x-mp-key). + * @param {Object} knownIdentities `{ email: string }` + * @param {Function} callback Invoked with the `IIdentitySearchResult`. + */ + search: function(workspaceApiKey, knownIdentities, callback) { + executeSearchRequest( + mpInstance, + workspaceApiKey, + knownIdentities, + callback + ); + }, + /** Create a default AliasRequest for 2 MParticleUsers. This will construct the request using the sourceUser's firstSeenTime as the startTime, and its lastSeenTime as the endTime. diff --git a/src/identity/search.ts b/src/identity/search.ts new file mode 100644 index 000000000..9be25e956 --- /dev/null +++ b/src/identity/search.ts @@ -0,0 +1,235 @@ +import Constants, { HTTP_OK, HTTP_NOT_FOUND } from '../constants'; +import { SDKLoggerApi } from '../sdkRuntimeModels'; +import { Environment, isFunction } from '../utils'; +import { + AsyncUploader, + FetchUploader, + IFetchPayload, + XHRUploader, +} from '../uploaders'; +import { + ErrorCodes, + IErrorReportingService, + WSDKErrorSeverity, +} from '../reporting/types'; + +const { HTTPCodes } = Constants; + +/** + * Shape of `known_identities` accepted by `search`. + * + * The IDSync `/v1/search` endpoint accepts the same identity keys as + * `/v1/identify`, but for v1 of this client API we only support `email`. + * Additional identity types can be added here in the future without breaking + * existing consumers. + */ +export interface IIdentitySearchKnownIdentities { + email: string; +} + +/** + * Body payload returned by the `/v1/search` endpoint, as parsed JSON. + * + * The shape mirrors `/v1/identify` responses. All fields are optional because + * non-200 responses (e.g. 404 NOT_FOUND_ERROR) may include partial or + * error-shaped bodies, and the consumer should only rely on body fields when + * `httpCode === 200`. + */ +export interface IIdentitySearchResponseBody { + context?: string | null; + mpid?: string; + matched_identities?: Record; + is_ephemeral?: boolean; + is_logged_in?: boolean; +} + +/** + * Result delivered to the consumer's callback. `httpCode` is always present; + * `body` is present whenever the response had a parseable JSON body. + * + * For non-network errors (missing API key, validation failures, JSON parse + * errors) `httpCode` will be `HTTPCodes.noHttpCoverage` (-1) and `body` will + * be omitted. The consumer is expected to gate behaviour on + * `httpCode === 200`. + */ +export interface IIdentitySearchResult { + httpCode: number; + body?: IIdentitySearchResponseBody; +} + +export type IdentitySearchCallback = (result: IIdentitySearchResult) => void; + +/** + * Body posted to `/v1/search`. Mirrors the `/v1/identify` request envelope so + * that the IDSync service can correlate requests across endpoints. + */ +export interface IIdentitySearchRequestBody { + client_sdk: { + platform: string; + sdk_vendor: string; + sdk_version: string; + }; + environment: Environment; + request_id: string; + request_timestamp_ms: number; + known_identities: IIdentitySearchKnownIdentities; +} + +interface IIdentitySearchPayload extends IFetchPayload { + headers: { + Accept: string; + 'Content-Type': string; + 'x-mp-key': string; + }; +} + +/** + * Sends a POST to mParticle's IDSync Search endpoint and invokes `callback` + * with the HTTP status and parsed body. + * + * Defensive contract: + * - Missing/invalid `email` -> callback with `{ httpCode: noHttpCoverage }`, + * no network call. + * - Missing `apiKey` -> callback with `{ httpCode: noHttpCoverage }`, + * no network call. + * - Network/JSON-parse errors are caught and surfaced via the callback, + * never thrown. Network errors are also reported through the optional + * `errorReporter` so any registered IErrorReportingService can observe + * them (matches the pattern used by identifyRequest in identityApiClient). + */ +export const sendSearchRequest = async ( + knownIdentities: IIdentitySearchKnownIdentities, + apiKey: string, + requestBuilder: () => Omit, + searchUrl: string, + callback: IdentitySearchCallback, + logger: SDKLoggerApi, + uploader?: AsyncUploader, + errorReporter?: IErrorReportingService, +): Promise => { + // Validate the callback up front. If it isn't a function we have nowhere + // to deliver a result to, so log and bail out without invoking anything. + if (!isFunction(callback)) { + logger.error( + 'search called without a callback function; skipping request.', + ); + return; + } + + const safeInvoke = (result: IIdentitySearchResult): void => { + try { + callback(result); + } catch (e) { + logger.error( + 'Error invoking search callback: ' + + ((e as Error)?.message || String(e)), + ); + } + }; + + // No valid email -> deliver httpCode: noHttpCoverage so callers waiting on + // the callback (e.g. to clear a loading state) don't hang. + if (!knownIdentities || typeof knownIdentities.email !== 'string' || !knownIdentities.email) { + logger.verbose( + 'search called without a valid email; skipping request.', + ); + safeInvoke({ httpCode: HTTPCodes.noHttpCoverage }); + return; + } + + // No API key -> same: deliver noHttpCoverage rather than hanging. + if (!apiKey) { + logger.verbose( + 'search called without a workspace API key; skipping request.', + ); + safeInvoke({ httpCode: HTTPCodes.noHttpCoverage }); + return; + } + + // Wrap request setup AND the network call in the try/catch so any throw + // — from requestBuilder, JSON.stringify (e.g. circular refs), or + // uploader construction — flows into the catch below and the consumer's + // callback fires with noHttpCoverage rather than the async function + // rejecting and the caller hanging on a never-fired callback. + try { + const requestEnvelope = requestBuilder(); + const requestBody: IIdentitySearchRequestBody = { + ...requestEnvelope, + known_identities: { + email: knownIdentities.email, + }, + }; + + const fetchPayload: IIdentitySearchPayload = { + method: 'post', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-mp-key': apiKey, + }, + body: JSON.stringify(requestBody), + }; + + const api: AsyncUploader = + uploader || + (window.fetch + ? new FetchUploader(searchUrl) + : new XHRUploader(searchUrl)); + + logger.verbose('Sending search request to ' + searchUrl); + const response: Response = await api.upload(fetchPayload, searchUrl); + + let body: IIdentitySearchResponseBody | undefined; + + // FetchUploader returns a real Response with .json(); XHRUploader + // returns an XHR-shaped object with `responseText`. We tolerate both. + if (isFunction(response.json)) { + try { + body = (await response.json()) as IIdentitySearchResponseBody; + } catch (e) { + logger.verbose( + 'search response had no parseable JSON body.', + ); + } + } else { + const xhrLike = (response as unknown) as XMLHttpRequest; + if (xhrLike?.responseText) { + try { + body = JSON.parse(xhrLike.responseText) as IIdentitySearchResponseBody; + } catch (e) { + logger.verbose( + 'search XHR response was not valid JSON.', + ); + } + } + } + + if (response.status === HTTP_OK) { + logger.verbose('search received 200 OK.'); + } else if (response.status === HTTP_NOT_FOUND) { + // 404 NOT_FOUND_ERROR is an expected steady-state outcome and is + // intentionally not logged as an error. + logger.verbose('search received 404 (no match).'); + } else { + logger.verbose( + 'search received non-success status ' + response.status, + ); + } + + safeInvoke({ httpCode: response.status, body }); + } catch (e) { + const message = (e as Error)?.message || String(e); + const reportMessage = 'Error sending search request: ' + message; + logger.error(reportMessage); + // Mirror the identity-route pattern in identityApiClient.ts: log to + // console AND push a structured report through the dispatcher so any + // registered IErrorReportingService (e.g. the Rokt kit's) can observe + // the failure. + errorReporter?.report({ + message: reportMessage, + code: ErrorCodes.IDENTITY_REQUEST, + severity: WSDKErrorSeverity.ERROR, + }); + safeInvoke({ httpCode: HTTPCodes.noHttpCoverage }); + } +}; diff --git a/src/mparticle-instance-manager.ts b/src/mparticle-instance-manager.ts index f9312a662..5c51b51bb 100644 --- a/src/mparticle-instance-manager.ts +++ b/src/mparticle-instance-manager.ts @@ -394,6 +394,13 @@ function mParticleInstanceManager(this: IMParticleInstanceManager) { modify: function(identityApiData, callback) { self.getInstance().Identity.modify(identityApiData, callback); }, + search: function(workspaceApiKey, knownIdentities, callback) { + self.getInstance().Identity.search( + workspaceApiKey, + knownIdentities, + callback + ); + }, }; this.sessionManager = { diff --git a/src/public-types.ts b/src/public-types.ts index 15796f6cd..4b485ed48 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -56,6 +56,10 @@ export type { IAliasCallback, IAliasResult, SDKIdentityTypeEnum, + IIdentitySearchKnownIdentities, + IIdentitySearchResult, + IIdentitySearchResponseBody, + IdentitySearchCallback, } from './identity.interfaces'; // eCommerce diff --git a/src/stub/mparticle.stub.js b/src/stub/mparticle.stub.js index 237e4ac23..5a91c4a19 100644 --- a/src/stub/mparticle.stub.js +++ b/src/stub/mparticle.stub.js @@ -56,6 +56,7 @@ let mParticle = { login: voidFunction, logout: voidFunction, modify: voidFunction, + search: voidFunction, }, }; diff --git a/test/snippet/tests-snippet.js b/test/snippet/tests-snippet.js index de678fffe..9969d9471 100644 --- a/test/snippet/tests-snippet.js +++ b/test/snippet/tests-snippet.js @@ -81,6 +81,11 @@ describe('snippet', function() { mParticle.Identity.logout(userIdentities); mParticle.Identity.modify(userIdentities); mParticle.Identity.identify(userIdentities); + mParticle.Identity.search( + 'workspace_api_key', + { email: 'user@example.com' }, + function() {} + ); mParticle.config.rq[0][0].should.equal('Identity.login'); mParticle.config.rq[0][1].userIdentities.customerid.should.equal( 'test' @@ -97,6 +102,10 @@ describe('snippet', function() { mParticle.config.rq[3][1].userIdentities.customerid.should.equal( 'test' ); + mParticle.config.rq[4][0].should.equal('Identity.search'); + mParticle.config.rq[4][1].should.equal('workspace_api_key'); + mParticle.config.rq[4][2].email.should.equal('user@example.com'); + (typeof mParticle.config.rq[4][3]).should.equal('function'); done(); }); diff --git a/test/src/_test.index.ts b/test/src/_test.index.ts index 81513029e..5668cb68e 100644 --- a/test/src/_test.index.ts +++ b/test/src/_test.index.ts @@ -39,4 +39,5 @@ import './tests-identityApiClient'; import './tests-integration-capture'; import './tests-batchUploader_4'; import './tests-identity'; +import './tests-search'; diff --git a/test/src/tests-mparticle-instance-manager.ts b/test/src/tests-mparticle-instance-manager.ts index 0fcd9134c..a0fe0dc73 100644 --- a/test/src/tests-mparticle-instance-manager.ts +++ b/test/src/tests-mparticle-instance-manager.ts @@ -124,6 +124,7 @@ describe('mParticle instance manager', () => { 'getUsers', 'aliasUsers', 'createAliasRequest', + 'search', ]); expect(mParticle.Identity.HTTPCodes, 'HTTP Codes').to.have.keys([ 'noHttpCoverage', diff --git a/test/src/tests-search.ts b/test/src/tests-search.ts new file mode 100644 index 000000000..852dc3ff6 --- /dev/null +++ b/test/src/tests-search.ts @@ -0,0 +1,504 @@ +import sinon from 'sinon'; +import fetchMock from 'fetch-mock/esm/client'; +import { expect } from 'chai'; +import { apiKey, MPConfig, urls, testMPID } from './config/constants'; +import Constants from '../../src/constants'; +import { Logger } from '../../src/logger'; +import { IMParticleInstanceManager, SDKLoggerApi } from '../../src/sdkRuntimeModels'; +import { + IIdentitySearchResult, + sendSearchRequest, +} from '../../src/identity/search'; +import { buildIdentitySearchEnvelope } from '../../src/identity-utils'; +import Utils from './config/utils'; +const { fetchMockSuccess } = Utils; + +const { HTTPCodes } = Constants; + +declare global { + interface Window { + mParticle: IMParticleInstanceManager; + fetchMock: any; + } +} + +const searchUrl = `https://identity.mparticle.com/v1/search?cb=1`; + +const buildEnvelope = () => ({ + client_sdk: { + platform: 'web', + sdk_vendor: 'mparticle', + sdk_version: '2.66.0', + }, + environment: 'development' as const, + request_id: 'fixed-request-id', + request_timestamp_ms: 1735689600000, +}); + +describe('search', () => { + let logger: SDKLoggerApi; + + beforeEach(() => { + // Some tests below boot up window.mParticle to verify the public + // Identity.search surface; reset between tests so they + // don't interfere with each other. + window.mParticle._resetForTests(MPConfig); + fetchMockSuccess(urls.identify, { + mpid: testMPID, + is_logged_in: false, + }); + + logger = new Logger(window.mParticle.config); + }); + + afterEach(() => { + sinon.restore(); + fetchMock.restore(); + }); + + describe('sendSearchRequest (network layer)', () => { + it('invokes the callback with httpCode 200 and the parsed body on success', async () => { + const responseBody = { + context: 'ctx-123', + mpid: 'matched-mpid', + matched_identities: { email: 'hashed-email' }, + is_ephemeral: false, + is_logged_in: true, + }; + fetchMock.post(searchUrl, { + status: 200, + body: JSON.stringify(responseBody), + }); + + const callback = sinon.spy(); + await sendSearchRequest( + { email: 'user@example.com' }, + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as IIdentitySearchResult; + expect(result.httpCode).to.equal(200); + expect(result.body).to.deep.equal(responseBody); + }); + + it('forwards x-mp-key, content-type, and a JSON body matching the /v1/identify envelope', async () => { + fetchMock.post(searchUrl, { + status: 200, + body: JSON.stringify({ mpid: 'm' }), + }); + + await sendSearchRequest( + { email: 'user@example.com' }, + apiKey, + buildEnvelope, + searchUrl, + () => undefined, + logger, + ); + + const lastCall = fetchMock.lastCall(searchUrl); + expect(lastCall, 'POST was issued to the search URL').to.be.ok; + const init = lastCall![1] as RequestInit; + const headers = init.headers as Record; + expect(headers['x-mp-key']).to.equal(apiKey); + expect(headers['Content-Type']).to.equal('application/json'); + + const sentBody = JSON.parse(init.body as string); + expect(sentBody).to.have.keys( + 'client_sdk', + 'environment', + 'request_id', + 'request_timestamp_ms', + 'known_identities', + ); + expect(sentBody.known_identities).to.deep.equal({ + email: 'user@example.com', + }); + expect(sentBody.client_sdk).to.deep.equal({ + platform: 'web', + sdk_vendor: 'mparticle', + sdk_version: '2.66.0', + }); + expect(sentBody.environment).to.equal('development'); + }); + + it('surfaces httpCode 404 cleanly and parses NOT_FOUND_ERROR body without throwing', async () => { + const notFoundBody = { + Errors: [{ code: 'NOT_FOUND_ERROR', message: 'No match' }], + }; + fetchMock.post(searchUrl, { + status: 404, + body: JSON.stringify(notFoundBody), + }); + + const callback = sinon.spy(); + await sendSearchRequest( + { email: 'unknown@example.com' }, + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as IIdentitySearchResult; + expect(result.httpCode).to.equal(404); + // Body is best-effort parsed; we don't assert its exact shape + // beyond "it didn't throw". + expect(result.body).to.deep.equal(notFoundBody); + }); + + it('invokes the callback with noHttpCoverage when the API key is missing (no network call)', async () => { + const callback = sinon.spy(); + const requestBuilderSpy = sinon.spy(buildEnvelope); + + await sendSearchRequest( + { email: 'user@example.com' }, + '', + requestBuilderSpy, + searchUrl, + callback, + logger, + ); + + expect(fetchMock.calls(searchUrl).length).to.equal(0); + expect(requestBuilderSpy.called).to.eq(false); + // Missing apiKey: no network, but callback fires so callers can + // resolve any loading state they're holding open. + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as IIdentitySearchResult; + expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); + }); + + it('returns silently and does not throw when the callback is not a function', async () => { + const requestBuilderSpy = sinon.spy(buildEnvelope); + let threw = false; + try { + await sendSearchRequest( + { email: 'user@example.com' }, + apiKey, + requestBuilderSpy, + searchUrl, + (undefined as unknown) as any, + logger, + ); + } catch (e) { + threw = true; + } + expect(threw, 'should not throw on missing callback').to.eq(false); + expect(fetchMock.calls(searchUrl).length).to.equal(0); + expect(requestBuilderSpy.called).to.eq(false); + }); + + it('invokes the callback with noHttpCoverage when knownIdentities.email is missing or invalid (no network)', async () => { + const callback = sinon.spy(); + + await sendSearchRequest( + ({} as any), + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + + await sendSearchRequest( + ({ email: '' } as any), + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + + await sendSearchRequest( + ({ email: 12345 } as any), + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + + // Missing/invalid email: no network, but callback fires for each + // call so callers can resolve any pending loading state. + expect(fetchMock.calls(searchUrl).length).to.equal(0); + expect(callback.callCount).to.equal(3); + for (let i = 0; i < callback.callCount; i++) { + const result = callback.getCall(i).args[0] as IIdentitySearchResult; + expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); + } + }); + + it('catches network errors and surfaces noHttpCoverage via the callback (not thrown)', async () => { + fetchMock.post(searchUrl, { throws: new Error('network down') }); + + const callback = sinon.spy(); + let threw = false; + try { + await sendSearchRequest( + { email: 'user@example.com' }, + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + } catch (e) { + threw = true; + } + + expect(threw, 'should not throw on network error').to.eq(false); + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as IIdentitySearchResult; + expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); + }); + + it('catches errors thrown during request setup (e.g. requestBuilder) and surfaces noHttpCoverage via the callback', async () => { + // The try/catch must wrap requestBuilder, JSON.stringify, and + // uploader construction — not just the network call. If any + // synchronous setup step throws, the consumer's callback must + // still fire (otherwise a discarded Promise becomes an unhandled + // rejection and the consumer hangs on a never-fired callback). + const callback = sinon.spy(); + const throwingBuilder = () => { + throw new Error('builder boom'); + }; + let threw = false; + try { + await sendSearchRequest( + { email: 'user@example.com' }, + apiKey, + throwingBuilder, + searchUrl, + callback, + logger, + ); + } catch (e) { + threw = true; + } + + expect(threw, 'should not throw on setup error').to.eq(false); + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as IIdentitySearchResult; + expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); + // No network call should have been made. + expect(fetchMock.calls(searchUrl).length).to.equal(0); + }); + + it('reports a structured error through the supplied errorReporter on network failure', async () => { + fetchMock.post(searchUrl, { throws: new Error('network down') }); + + const callback = sinon.spy(); + const errorReporter = { report: sinon.spy() }; + + await sendSearchRequest( + { email: 'user@example.com' }, + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + undefined, + errorReporter, + ); + + expect(errorReporter.report.calledOnce).to.eq(true); + const reported = errorReporter.report.getCall(0).args[0]; + expect(reported.severity).to.equal('ERROR'); + expect(reported.code).to.equal('IDENTITY_REQUEST'); + expect(reported.message).to.match(/search/); + expect(reported.message).to.match(/network down/); + }); + + it('handles a non-JSON response body without throwing', async () => { + fetchMock.post(searchUrl, { + status: 200, + body: 'not-json', + headers: { 'Content-Type': 'text/plain' }, + }); + + const callback = sinon.spy(); + await sendSearchRequest( + { email: 'user@example.com' }, + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as IIdentitySearchResult; + expect(result.httpCode).to.equal(200); + expect(result.body).to.be.undefined; + }); + }); + + describe('buildIdentitySearchEnvelope', () => { + it('returns the SDK identifiers and the supplied environment with a generated request_id and timestamp', () => { + const before = new Date().getTime(); + const envelope = buildIdentitySearchEnvelope('production'); + const after = new Date().getTime(); + + expect(envelope.client_sdk).to.deep.equal({ + platform: Constants.platform, + sdk_vendor: Constants.sdkVendor, + sdk_version: Constants.sdkVersion, + }); + expect(envelope.environment).to.equal('production'); + expect(typeof envelope.request_id).to.equal('string'); + expect(envelope.request_id.length).to.be.greaterThan(0); + expect(typeof envelope.request_timestamp_ms).to.equal('number'); + expect(envelope.request_timestamp_ms).to.be.at.least(before); + expect(envelope.request_timestamp_ms).to.be.at.most(after); + }); + + it('forwards the development environment when called with development', () => { + expect(buildIdentitySearchEnvelope('development').environment).to.equal('development'); + }); + + it('returns a fresh request_id on every call', () => { + const a = buildIdentitySearchEnvelope('development').request_id; + const b = buildIdentitySearchEnvelope('development').request_id; + expect(a).to.not.equal(b); + }); + + it('does not include known_identities (caller folds those in)', () => { + const envelope = buildIdentitySearchEnvelope('development') as Record; + expect(envelope).to.not.have.property('known_identities'); + }); + }); + + describe('mParticle.Identity.search (public surface)', () => { + const workspaceApiKey = 'workspace_api_key'; + + beforeEach(() => { + window.mParticle.init(apiKey, window.mParticle.config); + }); + + it('is exposed on the Identity namespace', () => { + expect(typeof (window.mParticle.Identity as any).search).to.equal( + 'function', + ); + }); + + it('issues a POST to /v1/search with the caller-supplied x-mp-key and a known_identities email', async () => { + fetchMock.post(searchUrl, { + status: 200, + body: JSON.stringify({ mpid: 'matched' }), + }); + + const callback = sinon.spy(); + (window.mParticle.Identity as any).search( + workspaceApiKey, + { email: 'user@example.com' }, + callback, + ); + + // fetch-mock + the response.json() await chain need a few ticks + // before the callback resolves; flush the microtask queue. + await fetchMock.flush(true); + await new Promise(resolve => setTimeout(resolve, 10)); + + const lastCall = fetchMock.lastCall(searchUrl); + expect(lastCall, 'POST was issued to /v1/search').to.be.ok; + + const init = lastCall![1] as RequestInit; + const headers = init.headers as Record; + // Must use the workspace-supplied key, NOT the SDK's workspace token. + expect(headers['x-mp-key']).to.equal(workspaceApiKey); + expect(headers['x-mp-key']).to.not.equal(apiKey); + + const sentBody = JSON.parse(init.body as string); + expect(sentBody.known_identities).to.deep.equal({ + email: 'user@example.com', + }); + expect(sentBody.client_sdk.platform).to.equal('web'); + expect(sentBody.client_sdk.sdk_vendor).to.equal('mparticle'); + expect(typeof sentBody.request_id).to.equal('string'); + expect(typeof sentBody.request_timestamp_ms).to.equal('number'); + + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as IIdentitySearchResult; + expect(result.httpCode).to.equal(200); + expect(result.body).to.deep.equal({ mpid: 'matched' }); + }); + + it('does not throw and logs an error when called without a callback', () => { + expect(() => + (window.mParticle.Identity as any).search( + workspaceApiKey, + { email: 'user@example.com' }, + ), + ).to.not.throw(); + }); + + it('invokes the callback with noHttpCoverage (no network call) when the caller passes an empty apiKey', async () => { + fetchMock.post(searchUrl, { + status: 200, + body: JSON.stringify({ mpid: 'should-not-be-called' }), + }); + + const callback = sinon.spy(); + (window.mParticle.Identity as any).search( + '', + { email: 'user@example.com' }, + callback, + ); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(fetchMock.calls(searchUrl).length).to.equal(0); + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as IIdentitySearchResult; + expect(result.httpCode).to.equal(HTTPCodes.noHttpCoverage); + }); + + it('skips the request and invokes the callback with loggingDisabledOrMissingAPIKey when the SDK is opted out', async () => { + fetchMock.post(searchUrl, { + status: 200, + body: JSON.stringify({ mpid: 'should-not-be-called' }), + }); + + // Wait for init's /identify round-trip to finish so setOptOut isn't + // queued by `queueIfNotInitialized` (it's a no-op until the SDK is ready). + await new Promise(resolve => setTimeout(resolve, 50)); + + window.mParticle.setOptOut(true); + + const callback = sinon.spy(); + (window.mParticle.Identity as any).search( + workspaceApiKey, + { email: 'user@example.com' }, + callback, + ); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(fetchMock.calls(searchUrl).length).to.equal(0); + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as IIdentitySearchResult & { + getUser?: unknown; + getPreviousUser?: unknown; + }; + expect(result.httpCode).to.equal(HTTPCodes.loggingDisabledOrMissingAPIKey); + // Result must conform to IIdentitySearchResult: no body string, + // and none of the standard Identity-callback `getUser`/`getPreviousUser` + // helpers (which would leak through if `_Helpers.invokeCallback` were + // used to deliver this result). + expect(result.body).to.equal(undefined); + expect(result.getUser).to.equal(undefined); + expect(result.getPreviousUser).to.equal(undefined); + + // Restore opt-in so the next test's beforeEach reset isn't fighting state. + window.mParticle.setOptOut(false); + }); + }); +}); diff --git a/test/stub/tests-mParticle-stub.js b/test/stub/tests-mParticle-stub.js index ee85da019..db37ce122 100644 --- a/test/stub/tests-mParticle-stub.js +++ b/test/stub/tests-mParticle-stub.js @@ -125,6 +125,16 @@ describe('mParticle stubs', function() { (typeof aliasRequest.startTime).should.equal('number'); (typeof aliasRequest.endTime).should.equal('number'); + mParticle.Identity.identify(); + mParticle.Identity.login(); + mParticle.Identity.logout(); + mParticle.Identity.modify(); + mParticle.Identity.search( + 'workspace_api_key', + { email: 'user@example.com' }, + function() {} + ); + done(); });