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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/identity-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
import { IStore } from './store';
import type { IMParticleWebSDKInstance } from './mp-instance';
import {
IIdentitySearchKnownIdentities,
IIdentitySearchRequestBody,
IdentitySearchCallback,
sendSearchRequest,
Expand Down Expand Up @@ -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;
Expand Down
31 changes: 25 additions & 6 deletions src/identity.interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<Dictionary<ICachedIdentityCall>>;
Expand Down
2 changes: 1 addition & 1 deletion src/identity.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Constants, { HTTP_OK } from './constants';

Check failure on line 1 in src/identity.js

View workflow job for this annotation

GitHub Actions / Core Tests / Core SDK Tests

Declaration emit for this file requires using private name 'mParticleUserCart'. An explicit type annotation may unblock declaration emit.

Check failure on line 1 in src/identity.js

View workflow job for this annotation

GitHub Actions / Core Tests / Core SDK Tests

Declaration emit for this file requires using private name 'mParticleUser'. An explicit type annotation may unblock declaration emit.
import Types, { IdentityType } from './types';
import {
cacheOrClearIdCache,
Expand Down Expand Up @@ -745,7 +745,7 @@
*
* @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) {
Expand Down
46 changes: 22 additions & 24 deletions src/identity/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,11 @@
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.
*
Expand Down Expand Up @@ -72,7 +62,7 @@
environment: Environment;
request_id: string;
request_timestamp_ms: number;
known_identities: IIdentitySearchKnownIdentities;
known_identities: UserIdentities;
}

interface IIdentitySearchPayload extends IFetchPayload {
Expand All @@ -88,17 +78,17 @@
* 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
* `errorReporter` so any registered IErrorReportingService can observe
* them (matches the pattern used by identifyRequest in identityApiClient).
*/
export const sendSearchRequest = async (
knownIdentities: IIdentitySearchKnownIdentities,
knownIdentities: UserIdentities,
apiKey: string,
requestBuilder: () => Omit<IIdentitySearchRequestBody, 'known_identities'>,
searchUrl: string,
Expand All @@ -106,7 +96,7 @@
logger: SDKLoggerApi,
uploader?: AsyncUploader,
errorReporter?: IErrorReportingService,
): Promise<void> => {

Check failure on line 99 in src/identity/search.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=mParticle_mparticle-web-sdk&issues=AZ34Qi0zYYlVkE44IPYI&open=AZ34Qi0zYYlVkE44IPYI&pullRequest=1256
// 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)) {
Expand All @@ -127,11 +117,20 @@
}
};

// 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,
)
) {
Comment thread
rmi22186 marked this conversation as resolved.
logger.verbose(
'search called without a valid email; skipping request.',
'Identity search called with empty identifiers; skipping request.',
);
safeInvoke({ httpCode: HTTPCodes.noHttpCoverage });
return;
Expand All @@ -153,11 +152,10 @@
// 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 },
Comment thread
rmi22186 marked this conversation as resolved.
};

const fetchPayload: IIdentitySearchPayload = {
Expand Down
2 changes: 1 addition & 1 deletion src/public-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ export type {
IAliasCallback,
IAliasResult,
SDKIdentityTypeEnum,
IIdentitySearchKnownIdentities,
IIdentitySearchResult,
IIdentitySearchResponseBody,
IdentitySearchCallback,
IUserIdentities
} from './identity.interfaces';

// eCommerce
Expand Down
73 changes: 70 additions & 3 deletions test/src/tests-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -218,15 +218,15 @@ describe('search', () => {
);

await sendSearchRequest(
({ email: 12345 } as any),
({ email: 12345, other: null } as any),
apiKey,
buildEnvelope,
searchUrl,
callback,
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);
Expand All @@ -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') });

Expand Down
Loading