diff --git a/src/identity-utils.ts b/src/identity-utils.ts index 6a84b5485..929e5be47 100644 --- a/src/identity-utils.ts +++ b/src/identity-utils.ts @@ -16,7 +16,6 @@ import { import { IStore } from './store'; import type { IMParticleWebSDKInstance } from './mp-instance'; import { - IIdentitySearchKnownIdentities, IIdentitySearchRequestBody, IdentitySearchCallback, sendSearchRequest, @@ -358,7 +357,7 @@ export const buildIdentitySearchEnvelope = ( export const executeSearchRequest = ( mpInstance: IMParticleWebSDKInstance, workspaceApiKey: string, - knownIdentities: IIdentitySearchKnownIdentities, + knownIdentities: UserIdentities, callback: IdentitySearchCallback, ): void => { const { _Helpers, _Store, Logger, _ErrorReportingDispatcher } = mpInstance; diff --git a/src/identity.interfaces.ts b/src/identity.interfaces.ts index 1a02400e6..a223b77d2 100644 --- a/src/identity.interfaces.ts +++ b/src/identity.interfaces.ts @@ -12,10 +12,7 @@ import { mParticleUserCart, IIdentityResponse, } from './identity-user-interfaces'; -import { - IIdentitySearchKnownIdentities, - IdentitySearchCallback, -} from './identity/search'; +import { IdentitySearchCallback } from './identity/search'; const { platform, sdkVendor, sdkVersion, HTTPCodes } = Constants; export type IdentityPreProcessResult = { @@ -173,18 +170,40 @@ export interface SDKIdentityApi { */ search?( workspaceApiKey: string, - knownIdentities: IIdentitySearchKnownIdentities, + knownIdentities: UserIdentities, callback: IdentitySearchCallback ): void; } export type { - IIdentitySearchKnownIdentities, IIdentitySearchResult, IIdentitySearchResponseBody, IdentitySearchCallback, } from './identity/search'; +export interface IUserIdentities { + customerid?: string; + email?: string; + other?: string; + other2?: string; + other3?: string; + other4?: string; + other5?: string; + other6?: string; + other7?: string; + other8?: string; + other9?: string; + other10?: string; + mobile_number?: string; + phone_number_2?: string; + phone_number_3?: string; + facebook?: string; + facebookcustomaudienceid?: string; + google?: string; + twitter?: string; + microsoft?: string; + yahoo?: string; +} export interface IIdentity { audienceManager: AudienceManager; idCache: BaseVault>; diff --git a/src/identity.js b/src/identity.js index 65cebd111..99e767e19 100644 --- a/src/identity.js +++ b/src/identity.js @@ -745,7 +745,7 @@ export default function Identity(mpInstance) { * * @method search * @param {String} workspaceApiKey Workspace API key (sent as x-mp-key). - * @param {Object} knownIdentities `{ email: string }` + * @param {Object} knownIdentities A `UserIdentities` map. * @param {Function} callback Invoked with the `IIdentitySearchResult`. */ search: function(workspaceApiKey, knownIdentities, callback) { diff --git a/src/identity/search.ts b/src/identity/search.ts index 9be25e956..934cfae1b 100644 --- a/src/identity/search.ts +++ b/src/identity/search.ts @@ -12,21 +12,11 @@ import { IErrorReportingService, WSDKErrorSeverity, } from '../reporting/types'; +import { IdentityApiData, UserIdentities } from '@mparticle/web-sdk'; +import Validators from '../validators'; 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. * @@ -72,7 +62,7 @@ export interface IIdentitySearchRequestBody { environment: Environment; request_id: string; request_timestamp_ms: number; - known_identities: IIdentitySearchKnownIdentities; + known_identities: UserIdentities; } interface IIdentitySearchPayload extends IFetchPayload { @@ -88,9 +78,9 @@ interface IIdentitySearchPayload extends IFetchPayload { * 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 identifier with a non-empty string value -> 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 @@ -98,7 +88,7 @@ interface IIdentitySearchPayload extends IFetchPayload { * them (matches the pattern used by identifyRequest in identityApiClient). */ export const sendSearchRequest = async ( - knownIdentities: IIdentitySearchKnownIdentities, + knownIdentities: UserIdentities, apiKey: string, requestBuilder: () => Omit, searchUrl: string, @@ -127,11 +117,20 @@ export const sendSearchRequest = async ( } }; - // 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) { + const cleanedKnownIdentities: UserIdentities = Validators.removeFalsyIdentityValues( + { userIdentities: knownIdentities ?? {} } as IdentityApiData, + logger, + ).userIdentities; + + // No usable identifier -> deliver httpCode: noHttpCoverage so callers + // waiting on the callback (e.g. to clear a loading state) don't hang. + if ( + !Object.values(cleanedKnownIdentities ?? {}).some( + (v) => typeof v === 'string' && v.length > 0, + ) + ) { logger.verbose( - 'search called without a valid email; skipping request.', + 'Identity search called with empty identifiers; skipping request.', ); safeInvoke({ httpCode: HTTPCodes.noHttpCoverage }); return; @@ -153,11 +152,10 @@ export const sendSearchRequest = async ( // rejecting and the caller hanging on a never-fired callback. try { const requestEnvelope = requestBuilder(); + const requestBody: IIdentitySearchRequestBody = { ...requestEnvelope, - known_identities: { - email: knownIdentities.email, - }, + known_identities: { ...cleanedKnownIdentities }, }; const fetchPayload: IIdentitySearchPayload = { diff --git a/src/public-types.ts b/src/public-types.ts index 4b485ed48..d84f52ab4 100644 --- a/src/public-types.ts +++ b/src/public-types.ts @@ -56,10 +56,10 @@ export type { IAliasCallback, IAliasResult, SDKIdentityTypeEnum, - IIdentitySearchKnownIdentities, IIdentitySearchResult, IIdentitySearchResponseBody, IdentitySearchCallback, + IUserIdentities } from './identity.interfaces'; // eCommerce diff --git a/test/src/tests-search.ts b/test/src/tests-search.ts index 852dc3ff6..011fa5083 100644 --- a/test/src/tests-search.ts +++ b/test/src/tests-search.ts @@ -196,7 +196,7 @@ describe('search', () => { expect(requestBuilderSpy.called).to.eq(false); }); - it('invokes the callback with noHttpCoverage when knownIdentities.email is missing or invalid (no network)', async () => { + it('invokes the callback with noHttpCoverage when no identifier has a non-empty string value (no network)', async () => { const callback = sinon.spy(); await sendSearchRequest( @@ -218,7 +218,7 @@ describe('search', () => { ); await sendSearchRequest( - ({ email: 12345 } as any), + ({ email: 12345, other: null } as any), apiKey, buildEnvelope, searchUrl, @@ -226,7 +226,7 @@ describe('search', () => { logger, ); - // Missing/invalid email: no network, but callback fires for each + // No usable identifier: 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); @@ -236,6 +236,73 @@ describe('search', () => { } }); + it('forwards non-email identifiers (e.g. hashed email in `other`) to the wire', async () => { + fetchMock.post(searchUrl, { + status: 200, + body: JSON.stringify({ mpid: '42' }), + }); + + const callback = sinon.spy(); + await sendSearchRequest( + { other: 'sha256:abc123', customerid: 'cust-1' }, + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + + expect(fetchMock.calls(searchUrl).length).to.equal(1); + const sentBody = JSON.parse( + fetchMock.calls(searchUrl)[0][1].body as string, + ); + expect(sentBody.known_identities).to.deep.equal({ + other: 'sha256:abc123', + customerid: 'cust-1', + }); + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as IIdentitySearchResult; + expect(result.httpCode).to.equal(200); + }); + + it('strips falsy identity values but preserves null on the wire', async () => { + // removeFalsyIdentityValues drops undefined / 0 / false / '' but + // explicitly preserves null (the server accepts it as a "no value" + // sentinel). Anything with at least one non-empty string value + // still triggers the network call. + fetchMock.post(searchUrl, { + status: 200, + body: JSON.stringify({ mpid: '99' }), + }); + + const callback = sinon.spy(); + await sendSearchRequest( + ({ + email: 'valid@example.com', + customerid: null, + other: '', + } as any), + apiKey, + buildEnvelope, + searchUrl, + callback, + logger, + ); + + expect(fetchMock.calls(searchUrl).length).to.equal(1); + const sentBody = JSON.parse( + fetchMock.calls(searchUrl)[0][1].body as string, + ); + // `other: ''` stripped, `customerid: null` preserved on the wire. + expect(sentBody.known_identities).to.deep.equal({ + email: 'valid@example.com', + customerid: null, + }); + expect(callback.calledOnce).to.eq(true); + const result = callback.getCall(0).args[0] as IIdentitySearchResult; + expect(result.httpCode).to.equal(200); + }); + it('catches network errors and surfaces noHttpCoverage via the callback (not thrown)', async () => { fetchMock.post(searchUrl, { throws: new Error('network down') });