diff --git a/CHANGELOG.md b/CHANGELOG.md index d083879d..8ccdfedd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # CHANGELOG +## 3.110.0 + +- SEPA + - Add support for new full page redirect flow + ## 3.109.0 - PayPal Checkout diff --git a/package-lock.json b/package-lock.json index 1cbcb280..05a8c809 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "braintree-web", - "version": "3.109.0", + "version": "3.110.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -8398,7 +8398,7 @@ } }, "jsdoc-template": { - "version": "git+ssh://git@github.com/braintree/jsdoc-template.git#04ccbb46addd8beabcbe1815fc81eb9d33e4c124", + "version": "github:braintree/jsdoc-template#04ccbb46addd8beabcbe1815fc81eb9d33e4c124", "from": "jsdoc-template@braintree/jsdoc-template#3.2.0", "dev": true }, diff --git a/package.json b/package.json index 1678bd1b..8c65d4a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "braintree-web", - "version": "3.109.0", + "version": "3.110.0", "license": "MIT", "main": "src/index.js", "private": true, diff --git a/src/client/client.js b/src/client/client.js index 3d42f166..d4c3d25b 100644 --- a/src/client/client.js +++ b/src/client/client.js @@ -9,7 +9,7 @@ var BraintreeError = require("../lib/braintree-error"); var convertToBraintreeError = require("../lib/convert-to-braintree-error"); var getGatewayConfiguration = require("./get-configuration").getConfiguration; var createAuthorizationData = require("../lib/create-authorization-data"); -var addMetadata = require("../lib/add-metadata"); +var metadata = require("../lib/add-metadata"); var wrapPromise = require("@braintree/wrap-promise"); var once = require("../lib/once"); var deferred = require("../lib/deferred"); @@ -321,7 +321,10 @@ Client.prototype.request = function (options, callback) { if (api === "clientApi") { baseUrl = self._clientApiBaseUrl; - requestOptions.data = addMetadata(self._configuration, options.data); + requestOptions.data = metadata.addMetadata( + self._configuration, + options.data + ); } else if (api === "graphQLApi") { baseUrl = GRAPHQL_URLS[self._configuration.gatewayConfiguration.environment]; diff --git a/src/lib/add-metadata.js b/src/lib/add-metadata.js index c946ce40..fab5d5d9 100644 --- a/src/lib/add-metadata.js +++ b/src/lib/add-metadata.js @@ -29,4 +29,38 @@ function addMetadata(configuration, data) { return attrs; } -module.exports = addMetadata; +function addEventMetadata(clientInstanceOrPromise) { + var configuration = clientInstanceOrPromise.getConfiguration(); + var authAttrs = createAuthorizationData(configuration.authorization).attrs; + var isProd = configuration.gatewayConfiguration.environment === "production"; + + /* eslint-disable camelcase */ + var metadata = { + api_integration_type: configuration.analyticsMetadata.integrationType, + app_id: window.location.host, + c_sdk_ver: constants.VERSION, + component: "braintreeclientsdk", + merchant_sdk_env: isProd ? "production" : "sandbox", + merchant_id: configuration.gatewayConfiguration.merchantId, + event_source: "web", + platform: constants.PLATFORM, + platform_version: window.navigator.userAgent, + session_id: configuration.analyticsMetadata.sessionId, + client_session_id: configuration.analyticsMetadata.sessionId, + tenant_name: "braintree", + }; + + if (authAttrs.tokenizationKey) { + metadata.tokenization_key = authAttrs.tokenizationKey; + } else { + metadata.auth_fingerprint = authAttrs.authorizationFingerprint; + } + /* eslint-enable camelcase */ + + return metadata; +} + +module.exports = { + addMetadata: addMetadata, + addEventMetadata: addEventMetadata, +}; diff --git a/src/lib/analytics.js b/src/lib/analytics.js index 915ff3b5..7483a999 100644 --- a/src/lib/analytics.js +++ b/src/lib/analytics.js @@ -1,46 +1,51 @@ "use strict"; var constants = require("./constants"); -var addMetadata = require("./add-metadata"); +var metadata = require("./add-metadata"); -function sendAnalyticsEvent(clientInstanceOrPromise, kind, callback) { - var timestamp = Date.now(); // milliseconds +function sendPaypalEvent(clientInstanceOrPromise, eventName, callback) { + var timestamp = Date.now(); return Promise.resolve(clientInstanceOrPromise) .then(function (client) { - var timestampInPromise = Date.now(); - var configuration = client.getConfiguration(); var request = client._request; - var url = configuration.gatewayConfiguration.analytics.url; + var url = constants.ANALYTICS_URL; + var qualifiedEvent = constants.ANALYTICS_PREFIX + eventName; + var configuration = client.getConfiguration(); + var isProd = + configuration.gatewayConfiguration.environment === "production"; var data = { - analytics: [ - { - kind: constants.ANALYTICS_PREFIX + kind, - isAsync: - Math.floor(timestampInPromise / 1000) !== - Math.floor(timestamp / 1000), + events: [], + tracking: [], + }; + var trackingMeta = metadata.addEventMetadata(client, data); + + trackingMeta.event_name = qualifiedEvent; // eslint-disable-line camelcase + trackingMeta.t = timestamp; // eslint-disable-line camelcase + + data.events = [ + { + level: "info", + event: qualifiedEvent, + payload: { + env: isProd ? "production" : "sandbox", timestamp: timestamp, }, - ], - }; + }, + ]; + data.tracking = [trackingMeta]; - request( + return request( { url: url, method: "post", - data: addMetadata(configuration, data), + data: data, timeout: constants.ANALYTICS_REQUEST_TIMEOUT_MS, }, callback ); }) .catch(function (err) { - // for all non-test cases, we don't provide a callback, - // so this error will always be swallowed. In this case, - // that's fine, it should only error when the deferred - // client fails to set up, in which case we don't want - // that error to report over and over again via these - // deferred analytics events if (callback) { callback(err); } @@ -48,5 +53,5 @@ function sendAnalyticsEvent(clientInstanceOrPromise, kind, callback) { } module.exports = { - sendEvent: sendAnalyticsEvent, + sendEvent: sendPaypalEvent, }; diff --git a/src/lib/constants.js b/src/lib/constants.js index b0764a1d..b8b416df 100644 --- a/src/lib/constants.js +++ b/src/lib/constants.js @@ -34,6 +34,7 @@ if (process.env.BRAINTREE_JS_ENV === "development") { module.exports = { ANALYTICS_PREFIX: PLATFORM + ".", ANALYTICS_REQUEST_TIMEOUT_MS: 2000, + ANALYTICS_URL: "https://www.paypal.com/xoplatform/logger/api/logger", ASSETS_URLS: ASSETS_URLS, CLIENT_API_URLS: CLIENT_API_URLS, FRAUDNET_SOURCE: "BRAINTREE_SIGNIN", diff --git a/src/sepa/external/mandate.js b/src/sepa/external/mandate.js index 46921012..d721604a 100644 --- a/src/sepa/external/mandate.js +++ b/src/sepa/external/mandate.js @@ -8,6 +8,7 @@ var useMin = require("../../lib/use-min"); var billingAddressOptions = require("../shared/constants").BILLING_ADDRESS_OPTIONS; var snakeCaseToCamelCase = require("../../lib/snake-case-to-camel-case"); +var assign = require("../../lib/assign").assign; var POPUP_WIDTH = 400; var POPUP_HEIGHT = 570; @@ -165,6 +166,10 @@ function openPopup(client, options) { }); } +function redirectPage(approvalUrl) { + window.location.href = approvalUrl; +} + function mandateApproved(params) { return params && params.success; } @@ -244,10 +249,44 @@ function handleApproval(client, options) { }); } +function handleApprovalForFullPageRedirect(client, options) { + return client + .request({ + api: "clientApi", + method: "get", + endpoint: "sepa_debit/" + options.cart_id, + }) + .then(function (response) { + var payload = response.sepaDebitMandateDetail; + + analytics.sendEvent(client, "sepa.redirect.mandate.approved"); + + assign(options, { + last4: payload.last4, + customerId: payload.merchantOrPartnerCustomerId, + mandateType: payload.mandateType, + bankReferenceToken: payload.bankReferenceToken, + }); + + return handleApproval(client, options); + }) + .then(function (response) { + analytics.sendEvent(client, "sepa.redirect.tokenization.success"); + + return response; + }) + .catch(function () { + analytics.sendEvent(client, "sepa.redirect.handle-approval.failed"); + throw new BraintreeError(sepaErrors.SEPA_TRANSACTION_FAILED); + }); +} + module.exports = { createMandate: createMandate, openPopup: openPopup, handleApproval: handleApproval, POPUP_WIDTH: POPUP_WIDTH, POPUP_HEIGHT: POPUP_HEIGHT, + redirectPage: redirectPage, + handleApprovalForFullPageRedirect: handleApprovalForFullPageRedirect, }; diff --git a/src/sepa/external/sepa.js b/src/sepa/external/sepa.js index 218a9ac7..f563a604 100644 --- a/src/sepa/external/sepa.js +++ b/src/sepa/external/sepa.js @@ -23,8 +23,17 @@ function SEPA(options) { this._assetsUrl = getConfiguration.gatewayConfiguration.assetsUrl + "/web/" + VERSION; this._isDebug = getConfiguration.isDebug; - this._returnUrl = this._assetsUrl + "/html/redirect-frame.html?success=1"; - this._cancelUrl = this._assetsUrl + "/html/redirect-frame.html?cancel=1"; + if (options.redirectUrl) { + this._returnUrl = options.redirectUrl; + this._cancelUrl = options.redirectUrl + "?cancel=1"; + this._isRedirectFlow = true; + } else { + this._returnUrl = this._assetsUrl + "/html/redirect-frame.html?success=1"; + this._cancelUrl = this._assetsUrl + "/html/redirect-frame.html?cancel=1"; + } + if (options.tokenizePayload) { + this.tokenizePayload = options.tokenizePayload; + } analytics.sendEvent(this._client, "sepa.component.initialized"); } @@ -69,6 +78,7 @@ function SEPA(options) { */ SEPA.prototype.tokenize = function (options) { var self = this; + var popupPromise; var createMandateOptions = assign( { cancelUrl: self._cancelUrl, returnUrl: self._returnUrl }, options @@ -90,10 +100,15 @@ SEPA.prototype.tokenize = function (options) { ); } - return mandates + popupPromise = mandates .createMandate(self._client, createMandateOptions) .then(function (mandateResponse) { analytics.sendEvent(self._client, "sepa.create-mandate.success"); + + if (self._isRedirectFlow) { + return mandates.redirectPage(mandateResponse.approvalUrl); + } + options.last4 = mandateResponse.last4; options.bankReferenceToken = mandateResponse.bankReferenceToken; @@ -101,7 +116,15 @@ SEPA.prototype.tokenize = function (options) { approvalUrl: mandateResponse.approvalUrl, assetsUrl: self._assetsUrl, }); - }) + }); + + // Must be outside of .then() to return from top-level function + if (self._isRedirectFlow) { + return Promise.resolve(); + } + + // At this point, we know the promise came from the popup flow + return popupPromise .then(function () { analytics.sendEvent(self._client, "sepa.mandate.approved"); diff --git a/src/sepa/index.js b/src/sepa/index.js index 51c3b79d..f7924c0c 100644 --- a/src/sepa/index.js +++ b/src/sepa/index.js @@ -8,6 +8,9 @@ var createDeferredClient = require("../lib/create-deferred-client"); var basicComponentVerification = require("../lib/basic-component-verification"); var wrapPromise = require("@braintree/wrap-promise"); var VERSION = process.env.npm_package_version; +var parse = require("../lib/querystring").parse; +var assign = require("../lib/assign").assign; +var mandate = require("./external/mandate"); /** * @static @@ -16,6 +19,7 @@ var VERSION = process.env.npm_package_version; * @param {Client} [options.client] A {@link Client} instance. * @param {string} [options.authorization] A tokenizationKey or clientToken. Can be used in place of `options.client`. * @param {boolean} [options.debug] A debug flag. + * @param {string} [options.redirectUrl] When provided, triggers full page redirect flow instead of popup flow. * @param {callback} [callback] When provided, will be used instead of a promise. First argument is an error object, where the second is an instance of {@link SEPA|SEPA}. * @returns {Promise} Returns the SEPA instance. * @example @@ -38,6 +42,7 @@ var VERSION = process.env.npm_package_version; function create(options) { var name = "SEPA"; + var params = parse(window.location.href); return basicComponentVerification .verify({ @@ -57,9 +62,37 @@ function create(options) { .then(function (client) { options.client = client; - analytics.sendEvent(options.client, "sepa.client.initialized"); + analytics.sendEvent(client, "sepa.client.initialized"); return new SEPA(options); + }) + .then(function (sepaInstance) { + // This cart_id is actually a V2 orderId + var redirectComplete = + params.success && params.success === "true" && params.cart_id; + + if (redirectComplete) { + options = assign(options, params); + + // Pick up redirect flow where it left off + return mandate + .handleApprovalForFullPageRedirect(options.client, options) + .then(function (payload) { + sepaInstance.tokenizePayload = payload; + + return sepaInstance; + }) + .catch(function (err) { + console.error("Problem while finishing tokenizing: ", err); + }); + } else if (params.cancel) { + analytics.sendEvent( + options.client, + "sepa.redirect.customer-canceled.failed" + ); + } + + return sepaInstance; }); } diff --git a/test/client/unit/client.js b/test/client/unit/client.js index 3f2234c5..2d556169 100644 --- a/test/client/unit/client.js +++ b/test/client/unit/client.js @@ -472,15 +472,13 @@ describe("Client", () => { it("calls driver with client for source in _meta if source is not provided", () => { const client = new Client(fakeConfiguration()); - jest.spyOn(client, "_request").mockReturnValue(null); - client.request( - { - endpoint: "payment_methods", - method: "get", - }, - () => {} - ); + jest.spyOn(client, "_request").mockReturnValue(null); // yieldsAsync + client.request({ + endpoint: "payment_methods", + method: "get", + }); + expect(client._request.mock.calls).not.toEqual([]); expect(client._request.mock.calls[0][0]).toMatchObject({ data: { _meta: { source: "client" } }, }); diff --git a/test/lib/unit/add-metadata.js b/test/lib/unit/add-metadata.js index f5039200..df40dd4d 100644 --- a/test/lib/unit/add-metadata.js +++ b/test/lib/unit/add-metadata.js @@ -1,64 +1,143 @@ "use strict"; -const addMetadata = require("../../../src/lib/add-metadata"); +const Client = require("../../../src/client/client"); +var constants = require("../../../src/lib/constants"); +const metadata = require("../../../src/lib/add-metadata"); +const { + fake: { configuration: fakeConfiguration }, +} = require("../../helpers"); function clientTokenWithFingerprint(authorizationFingerprint) { return btoa(JSON.stringify({ authorizationFingerprint })); } -describe("_setAttrs", () => { - it("sets tokenizationKey on the attributes", () => { - const actual = addMetadata( - { - authorization: "development_testing_merchant_id", - analyticsMetadata: {}, - }, - {} - ); - - expect(actual.tokenizationKey).toBe("development_testing_merchant_id"); - }); +describe("metadata", () => { + describe("build event metadata", () => { + const savedWindowLocation = window.location; + const savedWindowNavigator = window.navigator; + const testHost = "http://www.example.com/"; + const testMerchId = "merch-id-987"; + const fauxTokenizeKey = "fake_tokenize_key_567"; - it("sets authorizationFingerprint on the attributes", () => { - const actual = addMetadata( - { - authorization: clientTokenWithFingerprint("auth fingerprint"), - analyticsMetadata: {}, - }, - {} - ); + let client, config, testContext; - expect(actual.authorizationFingerprint).toBe("auth fingerprint"); - }); + beforeEach(() => { + testContext = { + configuration: fakeConfiguration(), + }; + testContext.configuration.gatewayConfiguration.merchantId = testMerchId; + testContext.configuration.authorization = fauxTokenizeKey; - it("sets _meta attributes from analyticsMetadata", () => { - const actual = addMetadata( - { - authorization: "development_testing_merchant_id", - analyticsMetadata: { - jibberish: "still there", - }, - }, - {} - ); - - expect(actual._meta.jibberish).toBe("still there"); - }); + client = new Client(testContext.configuration); + config = client.getConfiguration(); + + delete window.location; + + window.location = { + host: "http://www.example.com/", + }; + }); + + afterEach(() => { + delete window.location; + delete window.navigator; + + window.location = savedWindowLocation; + window.navigator = savedWindowNavigator; + }); + + it("sets api_integration_type", () => { + const configData = metadata.addEventMetadata(client); + + expect(configData.app_id).toBe(window.location.host); + }); + + it("sets app_id", () => { + const configData = metadata.addEventMetadata(client); + + expect(configData.app_id).toBe(testHost); + }); + + it("sets c_sdk_ver", () => { + const configData = metadata.addEventMetadata(client); + + expect(configData.c_sdk_ver).toBe(constants.VERSION); + }); + + it("sets component", () => { + const configData = metadata.addEventMetadata(client); + + expect(configData.component).toBe("braintreeclientsdk"); + }); + + it("sets merchant_sdk_env", () => { + const configData = metadata.addEventMetadata(client); + + expect(configData.merchant_sdk_env).toBe("sandbox"); + }); + + it("sets merchant_id", () => { + config.gatewayConfiguration.merchantId = testMerchId; + const configData = metadata.addEventMetadata(client); + + expect(configData.merchant_id).toBe(testMerchId); + }); + + it("sets event_source", () => { + const configData = metadata.addEventMetadata(client); + + expect(configData.event_source).toBe("web"); + }); + + it("sets platform", () => { + const configData = metadata.addEventMetadata(client); + + expect(configData.platform).toBe("web"); + }); + + it("sets platform_version", () => { + const testUserAgent = "computer-os-x.y.z-browser"; + + delete window.navigator; + window.navigator = { + userAgent: testUserAgent, + }; + const configData = metadata.addEventMetadata(client); + + expect(configData.platform_version).toBe(testUserAgent); + }); + + it("sets session_id", () => { + const configData = metadata.addEventMetadata(client); + + expect(configData.session_id).toBe(config.analyticsMetadata.sessionId); + }); + + it("sets tenant_name", () => { + const configData = metadata.addEventMetadata(client); + + expect(configData.tenant_name).toBe("braintree"); + }); + + it("sets tokenizationKey when present", () => { + const configData = metadata.addEventMetadata(client); + + expect(configData["tokenization_key"]).toBe(fauxTokenizeKey); // eslint-disable-line dot-notation + expect(configData["auth_fingerprint"]).toBeUndefined(); // eslint-disable-line dot-notation + }); + + it("sets auth_fingerprint when present", () => { + let configData; + const fauxAuthFingerprint = "fingerprint1234567890"; + + testContext.configuration.authorization = + clientTokenWithFingerprint(fauxAuthFingerprint); + + client = new Client(testContext.configuration); + config = client.getConfiguration(); - it("preserves existing _meta values", () => { - const actual = addMetadata( - { - authorization: "development_testing_merchant_id", - analyticsMetadata: { - jibberish: "still there", - }, - }, - { - _meta: { moreJibberish: "should also be there" }, - } - ); - - expect(actual._meta.jibberish).toBe("still there"); - expect(actual._meta.moreJibberish).toBe("should also be there"); + configData = metadata.addEventMetadata(client); + expect(configData["auth_fingerprint"]).toBe(fauxAuthFingerprint); // eslint-disable-line dot-notation + }); }); }); diff --git a/test/lib/unit/analytics.js b/test/lib/unit/analytics.js index 0237b7f9..7b08a2b2 100644 --- a/test/lib/unit/analytics.js +++ b/test/lib/unit/analytics.js @@ -1,10 +1,9 @@ "use strict"; const analytics = require("../../../src/lib/analytics"); -const constants = require("../../../src/lib/constants"); const { yieldsAsync } = require("../../helpers"); -describe("analytics.sendEvent", () => { +describe("analytics", () => { let testContext; beforeEach(() => { @@ -18,15 +17,9 @@ describe("analytics.sendEvent", () => { return testContext.fauxDate; }); + testContext.client = { _request: jest.fn(yieldsAsync()), - getConfiguration: () => ({ - authorization: "development_testing_merchant_id", - analyticsMetadata: { sessionId: "sessionId" }, - gatewayConfiguration: { - analytics: { url: "https://example.com/analytics-url" }, - }, - }), }; }); @@ -34,126 +27,132 @@ describe("analytics.sendEvent", () => { jest.useRealTimers(); }); - it("correctly sends an analytics event with a callback", (done) => { - analytics.sendEvent(testContext.client, "test.event.kind", () => { - const currentTimestamp = Date.now(); - const { timeout, url, method, data } = - testContext.client._request.mock.calls[0][0]; - - expect(testContext.client._request).toHaveBeenCalled(); - - expect(url).toBe("https://example.com/analytics-url"); - expect(method).toBe("post"); - expect(data.analytics[0].kind).toBe("web.test.event.kind"); - expect(data.braintreeLibraryVersion).toBe( - constants.BRAINTREE_LIBRARY_VERSION - ); - expect(data._meta.sessionId).toBe("sessionId"); - expect(currentTimestamp - data.analytics[0].timestamp).toBeLessThan(2000); - expect(currentTimestamp - data.analytics[0].timestamp).toBeGreaterThan(0); - expect(timeout).toBe(constants.ANALYTICS_REQUEST_TIMEOUT_MS); - expect(data.analytics[0].isAsync).toBe(false); - - done(); + describe("send loggernodeweb events", () => { + beforeEach(() => { + testContext.client.getConfiguration = () => { + return { + authorization: "development_testing_merchant_id", + analyticsMetadata: { + sessionId: "sessionId", + integrationType: "custom", + }, + gatewayConfiguration: { + analytics: { url: "https://example.com/analytics-url" }, + environment: "sandbox", + merchantId: "merchant-id", + }, + }; + }; }); - }); - - it("correctly sends an analytics event with no callback (fire-and-forget)", async () => { - testContext.client._request.mockReset(); - - analytics.sendEvent(testContext.client, "test.event.kind"); - - await Promise.resolve(() => jest.runAllTimers()); - - const currentTimestamp = Date.now(); - - expect(testContext.client._request).toBeCalledTimes(1); - - const postArgs = testContext.client._request.mock.calls[0]; - const { timeout, url, method, data } = postArgs[0]; - - expect(testContext.client._request).toHaveBeenCalled(); - expect(url).toBe("https://example.com/analytics-url"); - expect(method).toBe("post"); - expect(data.analytics[0].kind).toBe("web.test.event.kind"); - expect(data.braintreeLibraryVersion).toBe( - constants.BRAINTREE_LIBRARY_VERSION - ); - expect(data._meta.sessionId).toBe("sessionId"); - expect(currentTimestamp - data.analytics[0].timestamp).toBeLessThan(2000); - expect(currentTimestamp - data.analytics[0].timestamp).toBeGreaterThan(0); - expect(postArgs[1]).toBeFalsy(); - expect(timeout).toBe(constants.ANALYTICS_REQUEST_TIMEOUT_MS); - expect(data.analytics[0].isAsync).toBe(false); - }); - it("can send a deferred analytics event if client is a promise", (done) => { - const clientPromise = Promise.resolve(testContext.client); + it("passes client creation rejection to callback", (done) => { + const clientPromise = Promise.reject(new Error("failed to set up")); - analytics.sendEvent(clientPromise, "test.event.kind", () => { - const currentTimestamp = Date.now(); - const { timeout, url, method, data } = - testContext.client._request.mock.calls[0][0]; + analytics.sendEvent(clientPromise, "test.event.kind", (err) => { + expect(err.message).toBe("failed to set up"); - expect(testContext.client._request).toHaveBeenCalled(); - - expect(url).toBe("https://example.com/analytics-url"); - expect(method).toBe("post"); - expect(data.analytics[0].kind).toBe("web.test.event.kind"); - expect(data.braintreeLibraryVersion).toBe( - constants.BRAINTREE_LIBRARY_VERSION - ); - expect(data._meta.sessionId).toBe("sessionId"); - expect(currentTimestamp - data.analytics[0].timestamp).toBeLessThan(2000); - expect(currentTimestamp - data.analytics[0].timestamp).toBeGreaterThan(0); - expect(timeout).toBe(constants.ANALYTICS_REQUEST_TIMEOUT_MS); - expect(data.analytics[0].isAsync).toBe(false); - - done(); + done(); + }); }); - }); - it("passes client creation rejection to callback", (done) => { - const clientPromise = Promise.reject(new Error("failed to set up")); + it("ignores errors when client promise rejects and no callback is passed", async () => { + let err; + const clientPromise = Promise.reject(new Error("failed to set up")); - analytics.sendEvent(clientPromise, "test.event.kind", (err) => { - expect(err.message).toBe("failed to set up"); + try { + await analytics.sendEvent(clientPromise, "test.event.kind"); + } catch (e) { + err = e; + } - done(); + expect(err).toBeFalsy(); }); - }); - it("ignores errors when client promise rejects and no callback is passed", async () => { - let err; - const clientPromise = Promise.reject(new Error("failed to set up")); + it("sets timestamp to the time when the event was initialized, not when it was sent", (done) => { + const client = testContext.client; + + testContext.fauxDate += 1500; + const clientPromise = Promise.resolve(client); + + /* eslint-disable new-cap */ + client._request = jest.fn().mockImplementation((options, cb) => { + if (cb) { + cb(); + } + + return Promise(); + }); + + analytics.sendEvent(clientPromise, "test.event.kind", () => { + const currentTimestamp = Date.now(); + /* eslint-disable camelcase */ + const eventData = client._request.mock.calls[0][0].data; + const timestamp = eventData.events[0].payload.timestamp; + /* eslint-disable camelcase */ + const tenant_name = eventData.tracking[0].tenant_name; + + expect(currentTimestamp - timestamp).toBeLessThan(2000); + expect(currentTimestamp - timestamp).toBeGreaterThan(0); + expect(tenant_name).toBe( + "braintree" + ); /* eslint-disable-line camelcase */ + + jest.runAllTimers(); + done(); + }); + }); - try { - await analytics.sendEvent(clientPromise, "test.event.kind"); - } catch (e) { - err = e; - } + it("sends specified event/analytic", (done) => { + const expectedEventName = "api.module.thing.happened"; + const client = testContext.client; - expect(err).toBeFalsy(); - }); + analytics.sendEvent(client, expectedEventName, () => { + /* eslint-disable camelcase */ + const actualEventName = + /* eslint-disable camelcase */ + client._request.mock.calls[0][0].events[0].event; - it("sets timestamp to the time when the event was initialized, not when it was sent", (done) => { - const client = testContext.client; + expect(actualEventName).toBe("web." + expectedEventName); - testContext.fauxDate += 1500; - const clientPromise = Promise.resolve(client); + jest.runAllTimers(); + }); - analytics.sendEvent(clientPromise, "test.event.kind", () => { - const currentTimestamp = Date.now(); - const { timestamp, isAsync } = - client._request.mock.calls[0][0].data.analytics[0]; + done(); + }); - expect(currentTimestamp - timestamp).toBeLessThan(2000); - expect(currentTimestamp - timestamp).toBeGreaterThan(0); - expect(isAsync).toBe(true); + it("sends expected event envelope", (done) => { + const expectedEventName = "api.module.thing.happened"; + const expectedEventEnvelope = { + events: [], + tracking: [], + }; + const client = testContext.client; + + analytics.sendEvent(client, expectedEventName, () => { + /* eslint-disable camelcase */ + const actualEventSent = + /* eslint-disable camelcase */ + client._request.mock.calls[0][0].data; + + expect(actualEventSent).toMatchObject(expectedEventEnvelope); + expect(actualEventSent).toHaveProperty("events"); + expect(actualEventSent).toHaveProperty("tracking"); + expect(actualEventSent.events[0].level).not.toBeNull(); + expect(actualEventSent.events[0].event).not.toBeNull(); + expect(actualEventSent.events[0].payload).not.toBeNull(); + /* eslint-disable camelcase */ + expect(actualEventSent.events[0].payload).toHaveProperty("env"); + /* eslint-disable camelcase */ + expect(actualEventSent.events[0].payload).toHaveProperty("timestamp"); + /* eslint-disable camelcase */ + expect(actualEventSent.events[0].event).toBe( + "web." + expectedEventName + ); + + jest.runAllTimers(); + }); done(); }); - - jest.runAllTimers(); }); }); diff --git a/test/sepa/unit/index.js b/test/sepa/unit/index.js index 77a277cc..f4b0bcf8 100644 --- a/test/sepa/unit/index.js +++ b/test/sepa/unit/index.js @@ -5,6 +5,7 @@ jest.mock("../../../src/lib/basic-component-verification"); jest.mock("../../../src/lib/create-deferred-client"); jest.mock("../../../src/lib/create-assets-url"); jest.mock("../../../src/sepa/external/sepa"); +jest.mock("../../../src/sepa/external/mandate"); const { fake } = require("../../helpers"); const { create } = require("../../../src/sepa"); @@ -12,6 +13,8 @@ const SEPA = require("../../../src/sepa/external/sepa"); const basicComponentVerification = require("../../../src/lib/basic-component-verification"); const createDeferredClient = require("../../../src/lib/create-deferred-client"); const analytics = require("../../../src/lib/analytics"); +const assign = require("../../../src/lib/assign").assign; +const mandate = require("../../../src/sepa/external/mandate"); describe("SEPA static methods", () => { describe("sepa.create", () => { @@ -86,5 +89,45 @@ describe("SEPA static methods", () => { "sepa.client.initialized" ); }); + + it("when success=true and cart_id provided, should call handleApprovalForFullPageRedirect", async () => { + const cartId = "12345"; + const prevUrl = window.location.href; + + mandate.handleApprovalForFullPageRedirect.mockImplementation(() => { + return Promise.resolve(); + }); + + Object.defineProperty(window, "location", { + configurable: true, + get() { + return { + href: "https://www.example.com?success=true&cart_id=" + cartId, + }; + }, + }); + const options = { + client: testContext.client, + accountHolderName: "Jane Doe", + iban: "1234567890101112131415", + countryCode: "US", + customerId: "1234567890", + mandateType: "ONE_OFF", + merchantAccountId: "9876543210", + }; + + await create(options); + + expect(mandate.handleApprovalForFullPageRedirect).toBeCalledWith( + options.client, + assign(options, { success: true, cart_id: cartId }) // eslint-disable-line camelcase + ); + Object.defineProperty(window, "location", { + configurable: true, + get() { + return { href: prevUrl }; + }, + }); + }); }); }); diff --git a/test/sepa/unit/mandate.js b/test/sepa/unit/mandate.js index 08cbeeef..4045863e 100644 --- a/test/sepa/unit/mandate.js +++ b/test/sepa/unit/mandate.js @@ -3,6 +3,7 @@ const { fake } = require("../../helpers"); const createDeferredClient = require("../../../src/lib/create-deferred-client"); const { + handleApprovalForFullPageRedirect, createMandate, openPopup, handleApproval, @@ -60,7 +61,7 @@ describe("mandate.js", () => { bankReferenceToken, merchantAccountId, }; - const mockSepaSucessResponse = { + const mockSepaSuccessResponse = { nonce: nonce, }; @@ -449,7 +450,7 @@ describe("mandate.js", () => { it("makes the http request", async () => { testContext.client.request = jest.fn(); - testContext.client.request.mockResolvedValue(mockSepaSucessResponse); + testContext.client.request.mockResolvedValue(mockSepaSuccessResponse); const expectedResult = { nonce, @@ -497,4 +498,31 @@ describe("mandate.js", () => { } }); }); + + describe("handleApprovalForFullPageRedirect()", () => { + it("sends expected events when successful", async () => { + testContext.client.request = jest.fn(); + testContext.client.request + .mockResolvedValueOnce({ + sepaDebitMandateDetail: { + last4: iban.slice(-4), + merchantOrPartnerCustomerId: customerId, + mandateType: mandateType, + bankReferenceToken: bankReferenceToken, + }, + }) + .mockResolvedValueOnce(mockSepaSuccessResponse); + + await handleApprovalForFullPageRedirect(testContext.client, payload); + + expect(analytics.sendEvent).toBeCalledWith( + testContext.client, + "sepa.redirect.mandate.approved" + ); + expect(analytics.sendEvent).toBeCalledWith( + testContext.client, + "sepa.redirect.tokenization.success" + ); + }); + }); }); diff --git a/test/sepa/unit/sepa.js b/test/sepa/unit/sepa.js index 35e8a46d..ca5103cc 100644 --- a/test/sepa/unit/sepa.js +++ b/test/sepa/unit/sepa.js @@ -81,6 +81,16 @@ describe("sepa.js", () => { mandateType: requiredInputs.mandateType, }); }); + jest + .spyOn(mandates, "handleApprovalForFullPageRedirect") + .mockImplementation(() => { + return Promise.resolve({ + nonce: mockNonce, + ibanLastFour: requiredInputs.iban.slice(-4), + customerId: requiredInputs.customerId, + mandateType: requiredInputs.mandateType, + }); + }); sepaInputs = { client: testContext.client, merchantId, @@ -269,28 +279,6 @@ describe("sepa.js", () => { } }); - it("should call handleApproval", async () => { - const sepaInstance = new SEPA(sepaInputs); - - await sepaInstance.tokenize(requiredInputs); - - const client = testContext.client; - - const expectedArgs = { - bankReferenceToken: requiredInputs.bankReferenceToken, - last4: requiredInputs.last4, - customerId: requiredInputs.customerId, - mandateType: requiredInputs.mandateType, - merchantAccountId: requiredInputs.merchantAccountId, - }; - - expect(mandates.handleApproval).toBeCalledWith(client, expectedArgs); - expect(analytics.sendEvent).toBeCalledWith( - sepaInputs.client, - "sepa.mandate.approved" - ); - }); - it("should complete tokenize process sucessfuly", async () => { const expectedResponse = { nonce: mockNonce, @@ -299,12 +287,19 @@ describe("sepa.js", () => { mandateType: requiredInputs.mandateType, }; const sepaInstance = new SEPA(sepaInputs); + + // Called when you make a new SEPA + expect(analytics.sendEvent).toBeCalledWith( + sepaInputs.client, + "sepa.component.initialized" + ); + const data = await sepaInstance.tokenize(requiredInputs); expect(data).toEqual(expectedResponse); expect(analytics.sendEvent).toBeCalledWith( sepaInputs.client, - "sepa.tokenization.success" + "sepa.create-mandate.success" ); });