diff --git a/.eslintrc.js b/.eslintrc.js index 53718735d..9d5c14c2c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -95,6 +95,7 @@ module.exports = { "import/extensions": "off", "camelcase": "off", "no-bitwise": "off", - "no-underscore-dangle": "off" + "no-underscore-dangle": "off", + 'no-restricted-syntax': ['off', 'ForOfStatement'], }, }; diff --git a/.github/workflows/E2E_SFRA.yml b/.github/workflows/E2E_SFRA.yml index 2e353b576..969570731 100644 --- a/.github/workflows/E2E_SFRA.yml +++ b/.github/workflows/E2E_SFRA.yml @@ -9,7 +9,7 @@ on: jobs: setup-the-cartridge: if: ${{ github.actor != 'renovate[bot]' || github.actor != 'lgtm-com[bot]' }} - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: diff --git a/jest/sfccCartridgeMocks.js b/jest/sfccCartridgeMocks.js index 938195ade..f9134b52d 100644 --- a/jest/sfccCartridgeMocks.js +++ b/jest/sfccCartridgeMocks.js @@ -20,6 +20,12 @@ jest.mock( { virtual: true }, ); +jest.mock( + '*/cartridge/adyen/scripts/expressPayments/createTemporaryBasket', + () => jest.fn(), + { virtual: true }, +); + jest.mock( '*/cartridge/adyen/scripts/expressPayments/selectShippingMethods', () => jest.fn(), @@ -467,4 +473,4 @@ jest.mock( getInstallmentValues: jest.fn(), }), { virtual: true }, -); \ No newline at end of file +); diff --git a/metadata/site_import/meta/system-objecttype-extensions.xml b/metadata/site_import/meta/system-objecttype-extensions.xml index c8614e6e1..82f7377ce 100644 --- a/metadata/site_import/meta/system-objecttype-extensions.xml +++ b/metadata/site_import/meta/system-objecttype-extensions.xml @@ -517,13 +517,6 @@ 0 0 - - Enable express checkout - boolean - false - false - true - Order of the express payment buttons If you want to change the order go to the new config page (Adyen Settings) @@ -539,6 +532,13 @@ false true + + Enable Apple Pay express on product detail page + boolean + false + false + true + Enable Amazon Pay express checkout boolean @@ -708,9 +708,9 @@ - + diff --git a/package.json b/package.json index 087263723..75c7993a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app_adyen_SFRA", - "version": "24.4.1", + "version": "24.4.2", "description": "Adyen's official cartridge for SFRA", "main": "index.js", "paths": { diff --git a/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/__tests__/applePayExpress.test.js b/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/__tests__/applePayExpress.test.js new file mode 100644 index 000000000..aa56a9e36 --- /dev/null +++ b/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/__tests__/applePayExpress.test.js @@ -0,0 +1,1022 @@ +/** + * @jest-environment jsdom + */ +const applePayExpressModule = require('../applePayExpress'); +const { + createApplePayButton, + initializeCheckout, + onAuthorized, + onShippingMethodSelected, + onShippingContactSelected, + handleAuthorised, + handleError +} = require("../applePayExpress"); + +const APPLE_PAY = 'applepay'; +const mockCreate = jest.fn(); + +let getPaymentMethods = applePayExpressModule.getPaymentMethods; +let formatCustomerObject = applePayExpressModule.formatCustomerObject; +let callPaymentFromComponent = applePayExpressModule.callPaymentFromComponent; +let selectShippingMethod = applePayExpressModule.selectShippingMethod; +let getShippingMethod = applePayExpressModule.getShippingMethod; +let spy; + +global.checkout = { create: mockCreate }; +global.fetch = jest.fn(); + +jest.mock('../applePayExpress', () => ({ + handleAuthorised: jest.fn(), + handleError: jest.fn(), + getPaymentMethods: jest.fn(), + formatCustomerObject: jest.fn(), + selectShippingMethod: jest.fn() +})); + +beforeAll(() => { + spy = jest.spyOn(document, 'querySelector'); +}); + +describe('formatCustomerObject', () => { + it('should correctly format customer and billing data', () => { + const customerData = { + addressLines: ['123 Main St', 'Apt 4B'], + locality: 'Springfield', + country: 'United States', + countryCode: 'US', + givenName: 'John', + familyName: 'Doe', + emailAddress: 'john.doe@example.com', + postalCode: '12345', + administrativeArea: 'IL', + phoneNumber: '555-555-5555', + }; + + const billingData = { + addressLines: ['456 Oak St'], + locality: 'Shelbyville', + country: 'United States', + countryCode: 'US', + givenName: 'John', + familyName: 'Doe', + postalCode: '67890', + administrativeArea: 'IN', + }; + + const expectedOutput = { + addressBook: { + addresses: {}, + preferredAddress: { + address1: '123 Main St', + address2: 'Apt 4B', + city: 'Springfield', + countryCode: { + displayValue: 'United States', + value: 'US', + }, + firstName: 'John', + lastName: 'Doe', + ID: 'john.doe@example.com', + postalCode: '12345', + stateCode: 'IL', + }, + }, + billingAddressDetails: { + address1: '456 Oak St', + address2: null, + city: 'Shelbyville', + countryCode: { + displayValue: 'United States', + value: 'US', + }, + firstName: 'John', + lastName: 'Doe', + postalCode: '67890', + stateCode: 'IN', + }, + customer: {}, + profile: { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + phone: '555-555-5555', + }, + }; + + const result = formatCustomerObject(customerData, billingData); + expect(result).toEqual(expectedOutput); + }); + + it('should handle missing address lines in billing data', () => { + const customerData = { + addressLines: ['123 Main St', 'Apt 4B'], + locality: 'Springfield', + country: 'United States', + countryCode: 'US', + givenName: 'Jane', + familyName: 'Doe', + emailAddress: 'jane.doe@example.com', + postalCode: '12345', + administrativeArea: 'IL', + phoneNumber: '555-123-4567', + }; + + const billingData = { + addressLines: ['789 Elm St'], + locality: 'Capital City', + country: 'United States', + countryCode: 'US', + givenName: 'Jane', + familyName: 'Doe', + postalCode: '98765', + administrativeArea: 'CA', + }; + + const expectedOutput = { + addressBook: { + addresses: {}, + preferredAddress: { + address1: '123 Main St', + address2: 'Apt 4B', + city: 'Springfield', + countryCode: { + displayValue: 'United States', + value: 'US', + }, + firstName: 'Jane', + lastName: 'Doe', + ID: 'jane.doe@example.com', + postalCode: '12345', + stateCode: 'IL', + }, + }, + billingAddressDetails: { + address1: '789 Elm St', + address2: null, + city: 'Capital City', + countryCode: { + displayValue: 'United States', + value: 'US', + }, + firstName: 'Jane', + lastName: 'Doe', + postalCode: '98765', + stateCode: 'CA', + }, + customer: {}, + profile: { + firstName: 'Jane', + lastName: 'Doe', + email: 'jane.doe@example.com', + phone: '555-123-4567', + }, + }; + + const result = formatCustomerObject(customerData, billingData); + expect(result).toEqual(expectedOutput); + }); + + it('should handle customer data with a single address line', () => { + const customerData = { + addressLines: ['123 Main St'], + locality: 'Springfield', + country: 'United States', + countryCode: 'US', + givenName: 'Alice', + familyName: 'Johnson', + emailAddress: 'alice.johnson@example.com', + postalCode: '54321', + administrativeArea: 'TX', + phoneNumber: '555-678-9101', + }; + + const billingData = { + addressLines: ['987 Maple St'], + locality: 'Metropolis', + country: 'United States', + countryCode: 'US', + givenName: 'Alice', + familyName: 'Johnson', + postalCode: '76543', + administrativeArea: 'FL', + }; + + const expectedOutput = { + addressBook: { + addresses: {}, + preferredAddress: { + address1: '123 Main St', + address2: null, + city: 'Springfield', + countryCode: { + displayValue: 'United States', + value: 'US', + }, + firstName: 'Alice', + lastName: 'Johnson', + ID: 'alice.johnson@example.com', + postalCode: '54321', + stateCode: 'TX', + }, + }, + billingAddressDetails: { + address1: '987 Maple St', + address2: null, + city: 'Metropolis', + countryCode: { + displayValue: 'United States', + value: 'US', + }, + firstName: 'Alice', + lastName: 'Johnson', + postalCode: '76543', + stateCode: 'FL', + }, + customer: {}, + profile: { + firstName: 'Alice', + lastName: 'Johnson', + email: 'alice.johnson@example.com', + phone: '555-678-9101', + }, + }; + + const result = formatCustomerObject(customerData, billingData); + expect(result).toEqual(expectedOutput); + }); +}); + +describe('handleAuthorised', () => { + let mockResolveApplePay; + let mockQuerySelector; + let mockResultInput; + let mockFormSubmit; + + beforeEach(() => { + mockResolveApplePay = jest.fn(); + mockResultInput = { + value: '', + }; + mockFormSubmit = jest.fn(); + mockQuerySelector = jest.spyOn(document, 'querySelector').mockImplementation((selector) => { + if (selector === '#result') { + return mockResultInput; + } + if (selector === '#showConfirmationForm') { + return { + submit: mockFormSubmit, + }; + } + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should handle authorized response and update the result input field', () => { + const response = { + fullResponse: { + pspReference: 'ABC123', + resultCode: 'Authorised', + paymentMethod: 'applepay', + donationToken: 'DONATION123', + amount: { + value: 1000, + currency: 'USD', + }, + }, + }; + + handleAuthorised(response, mockResolveApplePay); + expect(mockResolveApplePay).toHaveBeenCalled(); + expect(mockResultInput.value).toBe( + JSON.stringify({ + pspReference: 'ABC123', + resultCode: 'Authorised', + paymentMethod: 'applepay', + donationToken: 'DONATION123', + amount: { + value: 1000, + currency: 'USD', + }, + }) + ); + expect(mockFormSubmit).toHaveBeenCalled(); + }); + + it('should handle case where paymentMethod is missing but available in additionalData', () => { + const response = { + fullResponse: { + pspReference: 'XYZ789', + resultCode: 'Authorised', + additionalData: { + paymentMethod: 'creditcard', + }, + donationToken: 'DONATION456', + amount: { + value: 500, + currency: 'EUR', + }, + }, + }; + + handleAuthorised(response, mockResolveApplePay); + expect(mockResolveApplePay).toHaveBeenCalled(); + expect(mockResultInput.value).toBe( + JSON.stringify({ + pspReference: 'XYZ789', + resultCode: 'Authorised', + paymentMethod: 'creditcard', + donationToken: 'DONATION456', + amount: { + value: 500, + currency: 'EUR', + }, + }) + ); + expect(mockFormSubmit).toHaveBeenCalled(); + }); + + it('should handle case where some optional fields are missing', () => { + const response = { + fullResponse: { + pspReference: 'LMN456', + resultCode: 'Authorised', + amount: { + value: 750, + currency: 'GBP', + }, + }, + }; + handleAuthorised(response, mockResolveApplePay); + expect(mockResolveApplePay).toHaveBeenCalled(); + expect(mockResultInput.value).toBe( + JSON.stringify({ + pspReference: 'LMN456', + resultCode: 'Authorised', + paymentMethod: undefined, + donationToken: undefined, + amount: { + value: 750, + currency: 'GBP', + }, + }) + ); + expect(mockFormSubmit).toHaveBeenCalled(); + }); +}); + +describe('handleError', () => { + let mockRejectApplePay; + let mockQuerySelector; + let mockResultInput; + let mockFormSubmit; + + beforeEach(() => { + mockRejectApplePay = jest.fn(); + mockResultInput = { + value: '', + }; + mockFormSubmit = jest.fn(); + mockQuerySelector = jest.spyOn(document, 'querySelector').mockImplementation((selector) => { + if (selector === '#result') { + return mockResultInput; + } + if (selector === '#showConfirmationForm') { + return { + submit: mockFormSubmit, + }; + } + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should handle the error correctly and update the result input field', () => { + handleError(mockRejectApplePay); + expect(mockRejectApplePay).toHaveBeenCalled(); + expect(mockResultInput.value).toBe( + JSON.stringify({ + error: true, + }) + ); + expect(mockFormSubmit).toHaveBeenCalled(); + }); +}); + +describe('callPaymentFromComponent', () => { + const mockResolveApplePay = jest.fn(); + const mockRejectApplePay = jest.fn(); + const mockData = { some: 'data' }; + let mockElementDiv; + let mockElementForm + + beforeEach(() => { + jest.clearAllMocks(); + window.paymentFromComponentURL = '/test-url'; + window.showConfirmationAction = '/confirmation-action'; + mockElementForm = document.createElement('form'); + mockElementForm.setAttribute("id", "showConfirmationForm"); + mockElementDiv = document.createElement('div'); + mockElementDiv.setAttribute("id", "additionalDetailsHidden"); + spy.mockReturnValue(mockElementForm); + spy.mockReturnValue(mockElementDiv); + }); + + it('should call rejectApplePay on ajax fail', async () => { + global.$.ajax = jest.fn().mockImplementation(({ success }) => ({ + fail: (callback) => { + callback(); + }, + })); + await callPaymentFromComponent(mockData, mockResolveApplePay, mockRejectApplePay); + expect(mockRejectApplePay).toHaveBeenCalled(); + }); +}); + +describe('getShippingMethod', () => { + const mockBasketId = 'test-basket-id'; + const mockResponse = { status: 200, json: jest.fn().mockResolvedValue({}) }; + + beforeEach(() => { + jest.clearAllMocks(); + window.shippingMethodsUrl = '/test-shipping-methods-url'; + }); + + it('should handle fetch rejection', async () => { + fetch.mockRejectedValue(new Error('Fetch failed')); + try { + await getShippingMethod(null, mockBasketId); + } catch (error) { + expect(error.message).toBe('Fetch failed'); + } + }); +}); + +describe('initializeCheckout', () => { + let mockPaymentMethodsResponse; + let AdyenCheckout; + + beforeEach(() => { + jest.clearAllMocks(); + window.environment = 'test-env'; + window.clientKey = 'test-client-key'; + window.locale = 'en-US'; + mockPaymentMethodsResponse = { + json: jest.fn().mockResolvedValue({ + applicationInfo: { + some: 'info', + }, + }), + }; + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ + applicationInfo: { + some: 'info', + }, + }), + }) + getPaymentMethods = jest.fn().mockImplementation({ + json: jest.fn().mockResolvedValue({ + applicationInfo: { + some: 'info', + }, + }), + }) + AdyenCheckout = jest.fn().mockResolvedValueOnce({}) + }); + + it('should handle errors when getPaymentMethods fails', async () => { + try { + await initializeCheckout(); + } catch (error) { + expect(error.message).toBe('Fetch failed'); + } + }); +}); + +describe('createApplePayButton', () => { + const applePayButtonConfig = { configKey: 'configValue' }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call checkout.create with APPLE_PAY and applePayButtonConfig', async () => { + const mockButtonInstance = { + mount: {} + } + mockCreate.mockImplementationOnce(() => (mockButtonInstance)); + const result = await createApplePayButton(applePayButtonConfig); + expect(result).toMatchObject(mockButtonInstance); + }); + + it('should handle errors thrown by checkout.create', async () => { + const mockError = new Error('Failed to create Apple Pay button'); + mockCreate.mockRejectedValue(mockError); + try { + await createApplePayButton(applePayButtonConfig); + } catch (error) { + expect(error).toBe(mockError); + } + }); +}); + +describe('onAuthorized function', () => { + let resolve; + let reject; + let event; + let amountValue; + let merchantName; + let temporaryBasketId; + + beforeEach(() => { + resolve = jest.fn(); + reject = jest.fn(); + amountValue = 100; + merchantName = 'Test Merchant'; + temporaryBasketId = 'mocked-basket-id'; + window.digitsNumber = '2'; + event = { + payment: { + shippingContact: { mock: 'shipping' }, + billingContact: { mock: 'billing' }, + token: { + paymentData: 'mocked-payment-token', + }, + }, + }; + }); + + it('should resolve with the correct final price update', async () => { + formatCustomerObject = jest.fn().mockImplementation(() => { + return {} + }) + callPaymentFromComponent = jest.fn().mockImplementation((data, resolveApplePay) => { + resolveApplePay(); + }); + + await onAuthorized(resolve, reject, event, amountValue, merchantName); + + setTimeout(() => { + expect(formatCustomerObject).toHaveBeenCalled(); + + expect(callPaymentFromComponent).toHaveBeenCalledWith( + { + paymentMethod: { + type: 'APPLE_PAY', + applePayToken: event.payment.token.paymentData, + }, + paymentType: 'express', + customer: { mock: 'formattedCustomer' }, + basketId: 'mocked-basket-id', + }, + expect.any(Function), + reject + ); + + expect(resolve).toHaveBeenCalledWith({ + newTotal: { + type: 'final', + label: merchantName, + amount: '10000' + }, + }); + }) + }); + + it('should reject if an error occurs', async () => { + const error = new Error('mock error'); + + callPaymentFromComponent.mockImplementation(() => { + throw error; + }); + + await onAuthorized(resolve, reject, event, amountValue, merchantName); + + setTimeout(() => { + expect(reject).toHaveBeenCalledWith(error); + }) + }); + + it('should correctly calculate the amount with a different digitsNumber', async () => { + window.digitsNumber = '3'; + + callPaymentFromComponent.mockImplementation((data, resolveApplePay) => { + resolveApplePay(); + }); + + await onAuthorized(resolve, reject, event, amountValue, merchantName); + + setTimeout(() => { + expect(resolve).toHaveBeenCalledWith({ + newTotal: { + type: 'final', + label: merchantName, + amount: '100000', + }, + }); + }) + }); +}); + +describe('onShippingMethodSelected function', () => { + let resolve; + let reject; + let event; + let applePayButtonConfig; + let merchantName; + let temporaryBasketId; + let shippingMethodsData; + + beforeEach(() => { + resolve = jest.fn(); + reject = jest.fn(); + + applePayButtonConfig = { + amount: {}, + }; + + merchantName = 'Test Merchant'; + temporaryBasketId = 'mocked-basket-id'; + + shippingMethodsData = { + shippingMethods: [ + { ID: 'shipping-method-1', label: 'Standard Shipping' }, + { ID: 'shipping-method-2', label: 'Express Shipping' }, + ], + }; + + event = { + shippingMethod: { + identifier: 'shipping-method-1', + }, + }; + + jest.clearAllMocks(); + }); + + it('should resolve with the correct applePayShippingMethodUpdate when shipping method is selected successfully', async () => { + const mockCalculationResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + grandTotalAmount: { + value: '150.00', + currency: 'USD', + }, + }), + }; + selectShippingMethod = jest.fn().mockImplementation((data, resolveApplePay) => { + return mockCalculationResponse + }); + await onShippingMethodSelected(resolve, reject, event, applePayButtonConfig, merchantName, shippingMethodsData.shippingMethods); + + const matchingShippingMethod = { ID: 'shipping-method-1', label: 'Standard Shipping', shipmentUUID: '1234' } + + setTimeout(() => { + expect(selectShippingMethod).toHaveBeenCalledWith(matchingShippingMethod, temporaryBasketId); + expect(applePayButtonConfig.amount).toEqual({ + value: '150.00', + currency: 'USD', + }); + expect(resolve).toHaveBeenCalledWith({ + newTotal: { + type: 'final', + label: merchantName, + amount: '150.00', + }, + }); + expect(reject).not.toHaveBeenCalled(); + }) + }); + + it('should reject if selectShippingMethod returns an error', async () => { + const mockCalculationResponse = { + ok: false, + }; + selectShippingMethod.mockResolvedValue(mockCalculationResponse); + + await onShippingMethodSelected(resolve, reject, event, applePayButtonConfig, merchantName, shippingMethodsData.shippingMethods); + + const matchingShippingMethod = shippingMethodsData.shippingMethods[0]; + + setTimeout(() => { + expect(selectShippingMethod).toHaveBeenCalledWith(matchingShippingMethod, temporaryBasketId); + expect(reject).toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + }) + }); +}); + +describe('Test shipping method selection and calculation flow', () => { + let resolve; + let reject; + let event; + let temporaryBasketId; + let merchantName; + let shippingMethodsData; + + beforeEach(() => { + resolve = jest.fn(); + reject = jest.fn(); + + temporaryBasketId = 'mocked-basket-id'; + merchantName = 'Test Merchant'; + + event = { + shippingContact: { address: 'mocked-address' }, + shippingMethod: { + identifier: 'shipping-method-1', + }, + }; + shippingMethodsData = { + shippingMethods: [ + { ID: 'shipping-method-1', label: 'Standard Shipping' }, + { ID: 'shipping-method-2', label: 'Express Shipping' }, + ], + } + jest.clearAllMocks(); + }); + + it('should resolve with the correct applePayShippingContactUpdate when shipping method selection and calculation succeeds', async () => { + const mockShippingMethodsResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + shippingMethods: [ + { + ID: 'shipping-method-1', + displayName: 'Standard Shipping', + description: 'Arrives in 5-7 days', + shippingCost: { value: '5.00' }, + }, + ], + }), + }; + + getShippingMethod = jest.fn().mockImplementation((data, resolveApplePay) => { + return mockShippingMethodsResponse + }); + + const mockCalculationResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + grandTotalAmount: { + value: '105.00', + }, + }), + }; + + selectShippingMethod.mockResolvedValue(mockCalculationResponse); + + await onShippingMethodSelected(resolve, reject, event, { amount: {} }, merchantName, shippingMethodsData.shippingMethods); + + setTimeout(() => { + expect(getShippingMethod).toHaveBeenCalledWith(event.shippingContact, temporaryBasketId); + expect(selectShippingMethod).toHaveBeenCalledWith( + { + ID: 'shipping-method-1', + displayName: 'Standard Shipping', + description: 'Arrives in 5-7 days', + shippingCost: { value: '5.00' }, + }, + temporaryBasketId + ); + expect(resolve).toHaveBeenCalledWith({ + newShippingMethods: [ + { + label: 'Standard Shipping', + detail: 'Arrives in 5-7 days', + identifier: 'shipping-method-1', + amount: '5.00', + }, + ], + newTotal: { + type: 'final', + label: merchantName, + amount: '105.00', + }, + }); + expect(reject).not.toHaveBeenCalled(); + }) + }); + + it('should reject when getShippingMethod fails', async () => { + const mockShippingMethodsResponse = { + ok: false, + }; + + getShippingMethod = jest.fn().mockImplementation((data, resolveApplePay) => { + return mockShippingMethodsResponse + }); + + await onShippingMethodSelected(resolve, reject, event, { amount: {} }, merchantName, shippingMethodsData.shippingMethods); + + setTimeout(() => { + expect(getShippingMethod).toHaveBeenCalledWith(event.shippingContact, temporaryBasketId); + expect(reject).toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + }) + }); + + it('should reject when there are no shipping methods available', async () => { + const mockShippingMethodsResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + shippingMethods: [], + }), + }; + + getShippingMethod = jest.fn().mockImplementation((data, resolveApplePay) => { + return mockShippingMethodsResponse + }); + + await onShippingMethodSelected(resolve, reject, event, { amount: {} }, merchantName, shippingMethodsData.shippingMethods); + + expect(reject).toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('should reject when selectShippingMethod fails', async () => { + const mockShippingMethodsResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + shippingMethods: [ + { + ID: 'shipping-method-1', + displayName: 'Standard Shipping', + description: 'Arrives in 5-7 days', + shippingCost: { value: '5.00' }, + }, + ], + }), + }; + + getShippingMethod = jest.fn().mockImplementation((data, resolveApplePay) => { + return mockShippingMethodsResponse + }); + + const mockCalculationResponse = { + ok: false, + }; + + selectShippingMethod.mockResolvedValue(mockCalculationResponse); + + await onShippingMethodSelected(resolve, reject, event, { amount: {} }, merchantName, shippingMethodsData.shippingMethods); + + expect(reject).toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + }); +}); + + +describe('onShippingContactSelected', () => { + let resolve; + let reject; + let event; + let merchantName; + let temporaryBasketId; + + beforeEach(() => { + resolve = jest.fn(); + reject = jest.fn(); + + event = { + shippingContact: { address: '123 Test Street' }, + }; + + merchantName = 'Test Merchant'; + temporaryBasketId = 'mock-basket-id'; + + jest.clearAllMocks(); + }); + + it('should resolve with the correct applePayShippingContactUpdate when all operations succeed', async () => { + const mockShippingMethodsResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + shippingMethods: [ + { + ID: 'shipping-method-1', + displayName: 'Standard Shipping', + description: 'Arrives in 3-5 days', + shippingCost: { value: '5.00' }, + }, + ], + }), + }; + getShippingMethod = jest.fn().mockImplementation((data, resolveApplePay) => { + return mockShippingMethodsResponse + }); + + const mockCalculationResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + grandTotalAmount: { value: '105.00' }, + }), + }; + selectShippingMethod.mockResolvedValue(mockCalculationResponse); + + await onShippingContactSelected(resolve, reject, event, merchantName); + + setTimeout(() => { + expect(getShippingMethod).toHaveBeenCalledWith(event.shippingContact, temporaryBasketId); + expect(selectShippingMethod).toHaveBeenCalledWith( + { + ID: 'shipping-method-1', + displayName: 'Standard Shipping', + description: 'Arrives in 3-5 days', + shippingCost: { value: '5.00' }, + }, + temporaryBasketId + ); + + expect(resolve).toHaveBeenCalledWith({ + newShippingMethods: [ + { + label: 'Standard Shipping', + detail: 'Arrives in 3-5 days', + identifier: 'shipping-method-1', + amount: '5.00', + }, + ], + newTotal: { + type: 'final', + label: merchantName, + amount: '105.00', + }, + }); + + expect(reject).not.toHaveBeenCalled(); + }) + }); + + it('should reject when getShippingMethod fails', async () => { + const mockShippingMethodsResponse = { ok: false }; + getShippingMethod.mockResolvedValue(mockShippingMethodsResponse); + + await onShippingContactSelected(resolve, reject, event, merchantName); + + setTimeout(() => { + expect(getShippingMethod).toHaveBeenCalledWith(event.shippingContact, temporaryBasketId); + expect(reject).toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + }) + }); + + it('should reject when no shipping methods are available', async () => { + const mockShippingMethodsResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + shippingMethods: [], + }), + }; + getShippingMethod.mockResolvedValue(mockShippingMethodsResponse); + + await onShippingContactSelected(resolve, reject, event, merchantName); + + setTimeout(() => { + expect(reject).toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + }) + }); + + it('should reject when selectShippingMethod fails', async () => { + const mockShippingMethodsResponse = { + ok: true, + json: jest.fn().mockResolvedValue({ + shippingMethods: [ + { + ID: 'shipping-method-1', + displayName: 'Standard Shipping', + description: 'Arrives in 3-5 days', + shippingCost: { value: '5.00' }, + }, + ], + }), + }; + getShippingMethod.mockResolvedValue(mockShippingMethodsResponse); + + const mockCalculationResponse = { ok: false }; + selectShippingMethod.mockResolvedValue(mockCalculationResponse); + + await onShippingContactSelected(resolve, reject, event, merchantName); + + setTimeout(() => { + expect(selectShippingMethod).toHaveBeenCalledWith( + { + ID: 'shipping-method-1', + displayName: 'Standard Shipping', + description: 'Arrives in 3-5 days', + shippingCost: { value: '5.00' }, + }, + temporaryBasketId + ); + expect(reject).toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + }) + }); +}); diff --git a/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/adyen_checkout/__tests__/applePayExpress.test.js b/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/adyen_checkout/__tests__/applePayExpress.test.js index 90e358062..0a714af5d 100644 --- a/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/adyen_checkout/__tests__/applePayExpress.test.js +++ b/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/adyen_checkout/__tests__/applePayExpress.test.js @@ -10,7 +10,6 @@ const { formatCustomerObject, } = require('../../applePayExpress'); - beforeEach(() => { jest.clearAllMocks(); diff --git a/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/adyen_checkout/__tests__/makePartialPayment.test.js b/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/adyen_checkout/__tests__/makePartialPayment.test.js index 60aa1ab6c..a44dbd94b 100644 --- a/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/adyen_checkout/__tests__/makePartialPayment.test.js +++ b/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/adyen_checkout/__tests__/makePartialPayment.test.js @@ -1,8 +1,10 @@ /** * @jest-environment jsdom */ +jest.mock('../../commons'); const { makePartialPayment } = require('../makePartialPayment'); const store = require('../../../../../store'); +const {getPaymentMethods, fetchGiftCards} = require("../../commons"); let data; const giftCardHtml = `
@@ -64,6 +66,28 @@ beforeEach(() => { }, giftcardBrand: 'Givex', }; + getPaymentMethods.mockReturnValue({ + json: jest.fn().mockReturnValue({ + adyenConnectedTerminals: { uniqueTerminalIds: ['mocked_id'] }, + imagePath: 'example.com', + adyenDescriptions: {}, + }), + }); + const availableGiftCards = { + giftCards: [ + { + orderAmount: { + currency: 'EUR', + value: 15, + }, + remainingAmount: { + currency: 'EUR', + value: 100, + }, + }, + ], + } + fetchGiftCards.mockReturnValue(availableGiftCards); }); afterEach(() => { @@ -93,7 +117,7 @@ describe('Make partial payment request', () => { fail(); } catch (error) { expect(error.message).toBe('Partial payment error true'); - } + } }); it('should fail to make partial payment', async () => { diff --git a/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/adyen_checkout/__tests__/renderGenericComponent.test.js b/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/adyen_checkout/__tests__/renderGenericComponent.test.js index 1db1f3aa0..0d3c1186d 100644 --- a/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/adyen_checkout/__tests__/renderGenericComponent.test.js +++ b/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/adyen_checkout/__tests__/renderGenericComponent.test.js @@ -62,7 +62,7 @@ beforeEach(() => { })); window.installments = '[[0,2,["amex","hipercard"]]]'; store.checkout = { - options: {} + options: {} }; store.checkoutConfiguration = { amount: {value : 'mocked_amount', currency : 'mocked_currency'}, @@ -93,7 +93,7 @@ describe('Render Generic Component', () => { expect(getPaymentMethods).toBeCalled(); expect(store.checkoutConfiguration).toMatchSnapshot(); expect( - document.querySelector('input[type=radio][name=brandCode]').value, + document.querySelector('input[type=radio][name=brandCode]').value, ).toBeTruthy(); }); @@ -231,7 +231,7 @@ describe('Render Generic Component', () => { expect(giftCardSelection.style.display).toBe('none'); expect(giftCardSeparator.style.display).toBe('none'); }); - + it('should call removeGiftCards with isPartialPaymentExpired', () => { const renderGiftCardComponent = require('*/cartridge/client/default/js/adyen_checkout/renderGiftcardComponent'); const now = new Date().toISOString(); @@ -240,7 +240,7 @@ describe('Render Generic Component', () => { } store.partialPaymentsOrderObj = { orderAmount : { currency: 'USD', value: 100 } - } + } store.addedGiftCards = [ { orderAmount: { currency: 'USD', value: 30 }, @@ -259,7 +259,7 @@ describe('Render Generic Component', () => { expect(renderGiftCardComponent.removeGiftCards).toHaveBeenCalled(); done(); }); // Timeout needed for completition of the test - }); + }); it('should call removeGiftCards with cartModified', () => { const renderGiftCardComponent = require('*/cartridge/client/default/js/adyen_checkout/renderGiftcardComponent'); @@ -268,7 +268,7 @@ describe('Render Generic Component', () => { } store.partialPaymentsOrderObj = { orderAmount : { currency: 'USD', value: 100 } - } + } store.addedGiftCards = [ { orderAmount: { currency: 'USD', value: 30 }, @@ -285,8 +285,8 @@ describe('Render Generic Component', () => { expect(renderGiftCardComponent.removeGiftCards).toHaveBeenCalled(); expect(renderGiftCardComponent.showGiftCardWarningMessage).toHaveBeenCalled(); done(); - }); // Timeout needed for completition of the test - }); + }); // Timeout needed for completition of the test + }); it('should handle the else part correctly', () => { const renderGiftCardComponent = require('*/cartridge/client/default/js/adyen_checkout/renderGiftcardComponent'); @@ -298,7 +298,7 @@ describe('Render Generic Component', () => { }; store.partialPaymentsOrderObj = { orderAmount : { currency: 'USD', value: 50 }, - } + } store.addedGiftCards = [ { giftCard : {brand : 'givex'}, diff --git a/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/applePayExpress.js b/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/applePayExpress.js index d898f6cfa..b4f0b1fb1 100644 --- a/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/applePayExpress.js +++ b/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/applePayExpress.js @@ -154,7 +154,11 @@ function getShippingMethod(shippingContact, basketId, reject) { success(response) { return response; }, - }).fail(() => reject()); + }).fail(() => { + if (reject) { + reject(); + } + }); } async function initializeCheckout(paymentMethodsResponse) { diff --git a/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/product/expressPayments.js b/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/product/expressPayments.js new file mode 100644 index 000000000..8758de064 --- /dev/null +++ b/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/product/expressPayments.js @@ -0,0 +1,101 @@ +const applePayExpressModule = require('../applePayExpress'); +const { APPLE_PAY } = require('../constants'); +const { getPaymentMethods } = require('../commons'); + +let paymentMethodsResponse; + +function getProductForm(product) { + const $productInputEl = document.createElement('input'); + $productInputEl.setAttribute('id', 'selected-express-product'); + $productInputEl.setAttribute('name', 'selected-express-product'); + $productInputEl.setAttribute('type', 'hidden'); + $productInputEl.setAttribute('data-pid', `${product.id}`); + $productInputEl.setAttribute('data-basketId', ''); + $productInputEl.value = JSON.stringify(product); + const $productForm = document.createElement('form'); + $productForm.setAttribute('id', 'express-product-form'); + $productForm.setAttribute('name', 'express-product-form'); + $productForm.append($productInputEl); + return $productForm; +} + +function getValueForCurrency(amount, currency) { + const value = Math.round(amount * 10 ** window.fractionDigits); + return { value, currency }; +} + +function getExpressPaymentButtons(product) { + const expressMethodsConfig = { + [APPLE_PAY]: window.isApplePayExpressOnPdpEnabled === 'true', + }; + const enabledExpressPaymentButtons = []; + Object.keys(expressMethodsConfig).forEach((key) => { + if (expressMethodsConfig[key]) { + const $container = document.createElement('div'); + $container.setAttribute('id', `${key}-pdp`); + $container.setAttribute('class', `expressComponent ${key}`); + $container.setAttribute('data-method', `${key}`); + $container.setAttribute('data-pid', `${product.id}`); + enabledExpressPaymentButtons.push($container); + } + }); + return enabledExpressPaymentButtons; +} + +function renderApplePayButton(paymentMethods) { + applePayExpressModule.init(paymentMethods); +} + +function renderExpressPaymentButtons() { + $('body').on('product:renderExpressPaymentButtons', (e, response) => { + const { product = {}, paymentMethods } = response; + const $expressPaymentButtonsContainer = document.getElementById( + 'express-payment-buttons', + ); + if (product.readyToOrder && product.available) { + const { price, selectedQuantity } = product; + const { value, currency } = price.sales; + const amount = getValueForCurrency(value * selectedQuantity, currency); + window.basketAmount = JSON.stringify(amount); + const expressPaymentButtons = getExpressPaymentButtons(product); + const $productForm = getProductForm(product); + $expressPaymentButtonsContainer.replaceChildren( + ...expressPaymentButtons, + $productForm, + ); + renderApplePayButton(paymentMethods); + } else { + $expressPaymentButtonsContainer.replaceChildren(); + } + }); +} + +async function init() { + paymentMethodsResponse = await getPaymentMethods(); + $('body').on('product:updateAddToCart', (e, response) => { + $('body').trigger('product:renderExpressPaymentButtons', { + product: response.product, + paymentMethods: paymentMethodsResponse, + }); + }); + $(document).ready(async () => { + $.spinner().start(); + const dataUrl = $('.quantity-select').find('option:selected').data('url'); + const productVariation = await $.ajax({ + url: dataUrl, + method: 'get', + }); + if (productVariation?.product) { + $('body').trigger('product:renderExpressPaymentButtons', { + product: productVariation?.product, + paymentMethods: paymentMethodsResponse, + }); + } + $.spinner().stop(); + }); +} + +module.exports = { + init, + renderExpressPaymentButtons, +}; diff --git a/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/productDetail.js b/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/productDetail.js new file mode 100644 index 000000000..7be55cacf --- /dev/null +++ b/src/cartridges/app_adyen_SFRA/cartridge/client/default/js/productDetail.js @@ -0,0 +1,7 @@ +/* eslint-disable global-require */ +const processInclude = require('base/util'); + +$(document).ready(() => { + processInclude(require('base/product/detail')); + processInclude(require('./product/expressPayments')); +}); diff --git a/src/cartridges/app_adyen_SFRA/cartridge/templates/default/product/components/addToCartButtonExtension.isml b/src/cartridges/app_adyen_SFRA/cartridge/templates/default/product/components/addToCartButtonExtension.isml new file mode 100644 index 000000000..3cb9604c1 --- /dev/null +++ b/src/cartridges/app_adyen_SFRA/cartridge/templates/default/product/components/addToCartButtonExtension.isml @@ -0,0 +1,24 @@ + + + + + + + +
+ \ No newline at end of file diff --git a/src/cartridges/bm_adyen/cartridge/static/default/css/configurationSettings.css b/src/cartridges/bm_adyen/cartridge/static/default/css/configurationSettings.css index 6cf3d0a85..5033a1704 100644 --- a/src/cartridges/bm_adyen/cartridge/static/default/css/configurationSettings.css +++ b/src/cartridges/bm_adyen/cartridge/static/default/css/configurationSettings.css @@ -329,7 +329,7 @@ html { } #logos{ - margin:0 auto; + margin: 10px 0; justify-content:flex-start; display:flex; width:100%; @@ -430,6 +430,11 @@ ul{ padding-bottom: 95px; } +.draggable .item { + margin: 0 10px; + font-weight: 600; +} + .draggable-list li { background-color: #fff; display: flex; @@ -524,6 +529,7 @@ ul{ left: 89%; } -.additional-item{ - margin-left: 48px; +.additional-item { + margin-left: 32px; + margin-bottom: 0; } diff --git a/src/cartridges/bm_adyen/cartridge/static/default/js/adyenSettings.js b/src/cartridges/bm_adyen/cartridge/static/default/js/adyenSettings.js index 8cf0271fd..c5a3c9831 100644 --- a/src/cartridges/bm_adyen/cartridge/static/default/js/adyenSettings.js +++ b/src/cartridges/bm_adyen/cartridge/static/default/js/adyenSettings.js @@ -1,29 +1,49 @@ const expressPaymentMethods = [ { id: 'applepay', - name: 'ApplePayExpress_Enabled', - text: 'Apple Pay', + text: 'Apple Pay Express', icon: window.applePayIcon, - checked: window.isApplePayEnabled, + toggles: [ + { + name: 'ApplePayExpress_Enabled', + text: 'Cart / mini cart', + checked: window.isApplePayEnabled, + }, + { + name: 'ApplePayExpress_Pdp_Enabled', + text: 'Product details page', + checked: window.isApplePayExpressOnPdpEnabled, + }, + ], }, { id: 'amazonpay', - name: 'AmazonPayExpress_Enabled', - text: 'Amazon Pay', + text: 'Amazon Pay Express', icon: window.amazonPayIcon, - checked: window.isAmazonPayEnabled, + toggles: [ + { + name: 'AmazonPayExpress_Enabled', + text: 'Cart / mini cart', + checked: window.isAmazonPayEnabled, + }, + ], }, { id: 'paypal', - name: 'PayPalExpress_Enabled', - text: 'PayPal', + text: 'PayPal Express', icon: window.paypalIcon, - checked: window.isPayPalExpressEnabled, - reviewPage: window.isPayPalExpressReviewPageEnabled, - additionalField: { - name: 'PayPalExpress_ReviewPage_Enabled', - text: 'Show shopper order review page', - }, + toggles: [ + { + name: 'PayPalExpress_Enabled', + text: 'Cart / mini cart', + checked: window.isPayPalExpressEnabled, + }, + { + name: 'PayPalExpress_ReviewPage_Enabled', + text: 'Order review page', + checked: window.isPayPalExpressReviewPageEnabled, + }, + ], }, ]; @@ -137,8 +157,9 @@ document.addEventListener('DOMContentLoaded', () => { } function addExpressEventListeners() { - const draggables = document.querySelectorAll('.draggable'); - const dragListItems = document.querySelectorAll('.draggable-list li'); + // Targeting only cart/mini-cart list as PDP doesn't need a swapping logic for the moment + const draggables = draggableList.querySelectorAll('.draggable'); + const dragListItems = draggableList.querySelectorAll('.draggable-list li'); draggables.forEach((draggable) => { draggable.addEventListener('dragstart', dragStart); @@ -152,35 +173,41 @@ document.addEventListener('DOMContentLoaded', () => { }); } - function createExpressPaymentsComponent() { + function createExpressPaymentsComponent( + paymentMethodsArray, + draggableListContainer, + ) { const { expressMethodsOrder } = window; if (expressMethodsOrder) { const sortOrder = expressMethodsOrder.split(','); - expressPaymentMethods.sort( + paymentMethodsArray.sort( (a, b) => sortOrder.indexOf(a.id) - sortOrder.indexOf(b.id), ); } - expressPaymentMethods.forEach((item, index) => { + + paymentMethodsArray.forEach((item, index) => { const listItem = document.createElement('li'); listItem.setAttribute('data-index', index.toString()); - let additionalFieldHtml = ''; - if (item.additionalField) { - additionalFieldHtml = ` -
-

${item.additionalField.text}

-
-
- -
-
-
- `; + let togglesHtml = ''; + if (item.toggles?.length) { + item.toggles.forEach((toggle) => { + togglesHtml += ` +
+

${toggle.text}

+
+
+ +
+
+
+ `; + }); } listItem.innerHTML = ` @@ -195,23 +222,12 @@ document.addEventListener('DOMContentLoaded', () => { />

${item.text}

-
-
- -
-
- ${additionalFieldHtml} + ${togglesHtml} `; listItems.push(listItem); - - draggableList.appendChild(listItem); + draggableListContainer.appendChild(listItem); }); addExpressEventListeners(); @@ -632,5 +648,5 @@ document.addEventListener('DOMContentLoaded', () => { window.location.reload(); }); - createExpressPaymentsComponent(); + createExpressPaymentsComponent(expressPaymentMethods, draggableList); }); diff --git a/src/cartridges/bm_adyen/cartridge/templates/default/adyenSettings/navigationCard.isml b/src/cartridges/bm_adyen/cartridge/templates/default/adyenSettings/navigationCard.isml index 345074b60..2a819af0c 100644 --- a/src/cartridges/bm_adyen/cartridge/templates/default/adyenSettings/navigationCard.isml +++ b/src/cartridges/bm_adyen/cartridge/templates/default/adyenSettings/navigationCard.isml @@ -1,6 +1,14 @@
+
+
+ +
+
+ +
+