diff --git a/src/cdk/v2/destinations/rakuten/config.js b/src/cdk/v2/destinations/rakuten/config.js new file mode 100644 index 00000000000..6a4c93ea3ab --- /dev/null +++ b/src/cdk/v2/destinations/rakuten/config.js @@ -0,0 +1,34 @@ +const { getMappingConfig } = require('../../../../v0/util'); + +const ConfigCategories = { + TRACK: { + type: 'track', + name: 'propertiesMapping', + }, +}; +const mappingConfig = getMappingConfig(ConfigCategories, __dirname); +// Following contains the keys at item level mapping where key can be considered as destkey and value can be considered as sourcekey +const productProperties = { + skulist: 'sku', + qlist: 'quantity', + namelist: 'name', + brandlist: 'brand', + couponlist: 'coupon', + catidlist: 'categoryId', + catlist: 'category', + disamtlist: 'discountAmount', + distypelist: 'discountType', + isclearancelist: 'isClearance', + ismarketplacelist: 'isMarketPlace', + issalelist: 'isSale', + itmstatuslist: 'itmStatus', + marginlist: 'margin', + markdownlist: 'markdown', + shipidlist: 'shipId', + shipbylist: 'shipBy', + taxexemptlist: 'taxExempt', + sequencelist: 'sequence', +}; +// list of all properties that are required +const requiredProductProperties = ['skulist', 'qlist', 'namelist']; +module.exports = { ConfigCategories, mappingConfig, productProperties, requiredProductProperties }; diff --git a/src/cdk/v2/destinations/rakuten/data/propertiesMapping.json b/src/cdk/v2/destinations/rakuten/data/propertiesMapping.json new file mode 100644 index 00000000000..e04765faede --- /dev/null +++ b/src/cdk/v2/destinations/rakuten/data/propertiesMapping.json @@ -0,0 +1,101 @@ +[ + { + "sourceKeys": "properties.orderId", + "required": true, + "destKey": "ord" + }, + { + "sourceKeys": ["properties.tr", "properties.ranSiteID"], + "required": true, + "destKey": "tr" + }, + { + "sourceKeys": ["properties.land", "properties.landTime"], + "required": true, + "destKey": "land" + }, + { + "sourceKeys": ["properties.date", "properties.orderCompletedTime"], + "destKey": "date" + }, + { + "sourceKeys": ["properties.altord", "properties.alterOrderId"], + "destKey": "altord" + }, + { + "sourceKeys": "properties.currency", + "destKey": "cur" + }, + { + "sourceKeys": "properties.creditCardType", + "destKey": "cc" + }, + { + "sourceKeys": "properties.commReason", + "destKey": "commreason" + }, + { + "sourceKeys": "properties.isComm", + "destKey": "iscomm" + }, + { + "sourceKeys": "properties.consumed", + "destKey": "consumed" + }, + { + "sourceKeys": "properties.coupon", + "destKey": "coupon" + }, + { + "sourceKeys": ["properties.custId", "properties.customerId", "properties.userId"], + "destKey": "custid" + }, + { + "sourceKeys": ["properties.custScore", "properties.customerScore"], + "destKey": "custscore" + }, + { + "sourceKeys": ["properties.custStatus", "properties.customerStatus"], + "destKey": "custstatus" + }, + { + "sourceKeys": ["properties.dId", "properties.advertisingId"], + "destKey": "did" + }, + { + "sourceKeys": ["properties.disamt", "properties.discountAmout"], + "destKey": "disamt" + }, + { + "sourceKeys": ["properties.ordStatus", "properties.orderStatus"], + "destKey": "ordstatus" + }, + { + "sourceKeys": "properties.segment", + "destKey": "segment" + }, + { + "sourceKeys": "properties.shipcountry", + "destKey": "shipcountry" + }, + { + "sourceKeys": "properties.shipped", + "destKey": "shipped" + }, + { + "sourceKeys": ["properties.sitename", "properties.url", "context.page.url"], + "destKey": "sitename" + }, + { + "sourceKeys": "properties.storeId", + "destKey": "storeid" + }, + { + "sourceKeys": ["properties.storecat", "properties.storeCategory"], + "destKey": "storecat" + }, + { + "sourceKeys": "properties.currency", + "destKey": "cur" + } +] diff --git a/src/cdk/v2/destinations/rakuten/procWorkflow.yaml b/src/cdk/v2/destinations/rakuten/procWorkflow.yaml new file mode 100644 index 00000000000..9ee9b5c03a1 --- /dev/null +++ b/src/cdk/v2/destinations/rakuten/procWorkflow.yaml @@ -0,0 +1,39 @@ +bindings: + - name: EventType + path: ../../../../constants + - path: ../../bindings/jsontemplate + - name: defaultRequestConfig + path: ../../../../v0/util + - name: removeUndefinedAndNullValues + path: ../../../../v0/util + - path: ./utils + +steps: + - name: messageType + template: | + .message.type.toLowerCase(); + - name: validateInput + template: | + let messageType = $.outputs.messageType; + $.assert(messageType, "message Type is not present. Aborting"); + $.assert(messageType in {{$.EventType.([.TRACK])}}, "message type " + messageType + " is not supported"); + $.assertConfig(.destination.Config.mid, "Merchant ID is not present. Aborting"); + - name: prepareTrackPayload + condition: $.outputs.messageType === {{$.EventType.TRACK}} + template: | + const properties = $.constructProperties(.message); + const lineItems = $.constructLineItems(.message.properties) + $.context.payload = {...properties,...lineItems,xml:1, mid:.destination.Config.mid} + $.context.payload = $.removeUndefinedAndNullValues($.context.payload); + + - name: buildResponse + template: | + const response = $.defaultRequestConfig(); + response.params = $.context.payload; + response.method = "GET"; + response.endpoint = "https://track.linksynergy.com/ep"; + response.headers = { + "accept": "application/json", + "content-type": "application/json" + }; + response diff --git a/src/cdk/v2/destinations/rakuten/utils.js b/src/cdk/v2/destinations/rakuten/utils.js new file mode 100644 index 00000000000..fe37455a572 --- /dev/null +++ b/src/cdk/v2/destinations/rakuten/utils.js @@ -0,0 +1,70 @@ +const { InstrumentationError } = require('@rudderstack/integrations-lib'); +const { isDefinedAndNotNull } = require('rudder-transformer-cdk/build/utils'); +const { + mappingConfig, + ConfigCategories, + productProperties, + requiredProductProperties, +} = require('./config'); +const { constructPayload } = require('../../../../v0/util'); + +/** + * This fucntion constructs payloads based upon mappingConfig for Track call type + * @param {*} message + * @returns + */ +const constructProperties = (message) => { + const payload = constructPayload(message, mappingConfig[ConfigCategories.TRACK.name]); + return payload; +}; + +/** + * This fucntion build the item level list + * @param {*} properties + * @returns + */ +const constructLineItems = (properties) => { + // Validate the existence and non-emptiness of the 'products' array in 'properties' + if (!Array.isArray(properties?.products) || properties.products.length === 0) { + throw new InstrumentationError('Either properties.product is not an array or is empty'); + } + + const { products } = properties; + const productList = {}; + + // Iterate over product properties to construct the payload + Object.keys(productProperties).forEach((property) => { + const propertyKey = productProperties[property]; + + // Extract values for the current property from the 'products' array + const values = products.map((product) => + isDefinedAndNotNull(product?.[propertyKey]) ? product[propertyKey] : '', + ); + + // Validate if a required property is missing + if (requiredProductProperties.includes(property) && values.includes('')) { + throw new InstrumentationError(`${propertyKey} is a required field. Aborting`); + } + + // Include property in the payload if values are non-empty + if (values.some((element) => element !== '')) { + productList[property] = values.join('|'); + } + }); + + // Map 'amountList' by evaluating 'amount' or deriving it from 'price' and 'quantity' + const amountList = products.map((product) => { + if (!product?.amount && !product?.price) { + throw new InstrumentationError('Either amount or price is required for every product'); + } + + if (product.price) { + return product.quantity * product.price * 100; + } + return product.amount * 100; + }); + productList.amtlist = amountList.join('|'); + return productList; +}; + +module.exports = { constructProperties, constructLineItems }; diff --git a/src/cdk/v2/destinations/rakuten/utils.test.js b/src/cdk/v2/destinations/rakuten/utils.test.js new file mode 100644 index 00000000000..9cc7f5fd4cc --- /dev/null +++ b/src/cdk/v2/destinations/rakuten/utils.test.js @@ -0,0 +1,117 @@ +const { constructLineItems } = require('./utils'); +describe('constructLineItems', () => { + it('should return a non-empty object when given a valid properties object with at least one product', () => { + const properties = { + products: [ + { + name: 'Product 1', + sku: 'sku_1', + price: 10, + quantity: 2, + amount: 20, + }, + ], + }; + const result = constructLineItems(properties); + const expectedObj = { + namelist: 'Product 1', + skulist: 'sku_1', + qlist: '2', + amtlist: '2000', + }; + expect(result).toEqual(expectedObj); + }); + + it('should include all mapped properties in the returned object when present in at least one product', () => { + const properties = { + products: [ + { + name: 'Product 1', + category: 'Category 1', + sku: 'sku_1', + brand: 'Brand 1', + price: 10, + quantity: 2, + amount: 20, + }, + ], + }; + + const result = constructLineItems(properties); + + const expectedObj = { + namelist: 'Product 1', + catlist: 'Category 1', + skulist: 'sku_1', + brandlist: 'Brand 1', + qlist: '2', + amtlist: '2000', + }; + expect(result).toEqual(expectedObj); + }); + + it('should include amtlist property in the returned object with calculated values', () => { + const properties = { + products: [ + { + name: 'Product 1', + sku: 'sku_1', + price: 10, + quantity: 2, + }, + { + name: 'Product 2', + sku: 'sku_2', + price: 5, + quantity: 3, + }, + ], + }; + + const result = constructLineItems(properties); + + expect(result).toHaveProperty('amtlist'); + expect(result.amtlist).toBe('2000|1500'); + }); + + it('should throw an InstrumentationError when properties object is missing or has an empty products array', () => { + const properties = {}; + + expect(() => constructLineItems(properties)).toThrow( + 'Either properties.product is not an array or is empty', + ); + + properties.products = []; + + expect(() => constructLineItems(properties)).toThrow( + 'Either properties.product is not an array or is empty', + ); + }); + it('should throw an InstrumentationError when a product is missing quantity property', () => { + const properties = { + products: [ + { + name: 'Product 1', + sku: 'sku_1', + amount: '1234', + }, + ], + }; + expect(() => constructLineItems(properties)).toThrow('quantity is a required field. Aborting'); + }); + it('should throw an InstrumentationError when a product is missing both amount and price properties', () => { + const properties = { + products: [ + { + name: 'Product 1', + sku: 'sku_1', + quantity: 2, + }, + ], + }; + + expect(() => constructLineItems(properties)).toThrow( + 'Either amount or price is required for every product', + ); + }); +}); diff --git a/src/v0/destinations/rakuten/networkHandler.js b/src/v0/destinations/rakuten/networkHandler.js new file mode 100644 index 00000000000..1b16bd55389 --- /dev/null +++ b/src/v0/destinations/rakuten/networkHandler.js @@ -0,0 +1,103 @@ +const { NetworkError } = require('@rudderstack/integrations-lib'); +const { httpSend } = require('../../../adapters/network'); +const { + processAxiosResponse, + getDynamicErrorType, +} = require('../../../adapters/utils/networkUtils'); +const { TAG_NAMES } = require('../../util/tags'); +const { HTTP_STATUS_CODES } = require('../../util/constant'); + +const DESTINATION = 'RAKUTEN'; +const prepareProxyRequest = (request) => request; +const proxyRequest = async (request, destType) => { + const { endpoint, data, method, params, headers } = prepareProxyRequest(request); + const requestOptions = { + url: endpoint, + data, + params, + headers, + method, + }; + const response = await httpSend(requestOptions, { feature: 'proxy', destType }); + return response; +}; +const extractContent = (xmlPayload, tagName) => { + const pattern = new RegExp(`<${tagName}>(.*?)</${tagName}>`); + const match = xmlPayload.match(pattern); + return match ? match[1] : null; +}; + +const responseHandler = (destinationResponse) => { + const msg = `[${DESTINATION} Response Handler] - Request Processed Successfully`; + const { response, status } = destinationResponse; + if (status === 400) { + throw new NetworkError( + `Request failed with status: ${status} due to invalid Marketing Id`, + 400, + { + [TAG_NAMES.ERROR_TYPE]: getDynamicErrorType(status), + }, + destinationResponse, + ); + } + // Extract errors, good and bad between different tags + const badRecords = extractContent(response, 'bad'); + const errors = extractContent(response, 'error'); + + // For access denied for a mid rakuten sends status code 200 with response as <response> <error> Access denied </error> </response> + if (errors) { + throw new NetworkError( + `Request failed with status: ${status} due to ${errors}. Can you try to enable pixel tracking for this mid.`, + 400, + { + // status would be 200 but since no error type for this status code hence it will take it as aborted + [TAG_NAMES.ERROR_TYPE]: getDynamicErrorType(status), + }, + destinationResponse, + ); + } + if (parseInt(badRecords, 10)) { + throw new NetworkError( + `Request failed with status: ${status} with number of bad records ${badRecords}`, + 400, + { + // status would be 200 but since no error type for this status code hence it will take it as aborted + [TAG_NAMES.ERROR_TYPE]: getDynamicErrorType(status), + }, + destinationResponse, + ); + } + /* just puttting it here for 429 and 500's we dont have documentation for these two + neither we have any sample response but just in case if we recoeve non 2xx status + */ + if (status !== 200) { + throw new NetworkError( + `Request failed with status: ${status}`, + status, + { + [TAG_NAMES.ERROR_TYPE]: getDynamicErrorType(status), + }, + destinationResponse, + ); + } + // if no error or bad record is found and status is 200 the request is successfull + return { + status: HTTP_STATUS_CODES.OK, + message: msg, + destinationResponse, + }; +}; +// eslint-disable-next-line @typescript-eslint/naming-convention +class networkHandler { + constructor() { + this.responseHandler = responseHandler; + this.proxy = proxyRequest; + this.prepareProxy = prepareProxyRequest; + this.processAxiosResponse = processAxiosResponse; + } +} + +module.exports = { + networkHandler, + responseHandler +}; diff --git a/src/v0/destinations/rakuten/networkHandler.test.js b/src/v0/destinations/rakuten/networkHandler.test.js new file mode 100644 index 00000000000..70461c86c17 --- /dev/null +++ b/src/v0/destinations/rakuten/networkHandler.test.js @@ -0,0 +1,64 @@ +const { responseHandler } = require('./networkHandler'); +// Generated by CodiumAI + +describe('responseHandler', () => { + it('should return a success message with status code 200 when the request is successful and no bad records or errors are found', () => { + const destinationResponse = { + response: '<response></response>', + status: 200, + }; + + const result = responseHandler(destinationResponse); + + expect(result.status).toBe(200); + expect(result.message).toBe('[RAKUTEN Response Handler] - Request Processed Successfully'); + expect(result.destinationResponse).toEqual(destinationResponse); + }); + + it('should throw a NetworkError with status code 400 and error message when the response status is 400 due to invalid Marketing Id', () => { + const destinationResponse = { + response: '<response>Invalid marketing id</response>', + status: 400, + }; + expect(() => { + responseHandler(destinationResponse); + }).toThrow('Request failed with status: 400 due to invalid Marketing Id'); + }); + + it('should throw a NetworkError with status code 400 and error message when the response contains errors', () => { + const destinationResponse = { + response: '<response><error>Access denied</error></response>', + status: 200, + }; + expect(() => { + responseHandler(destinationResponse); + }).toThrow( + 'Request failed with status: 200 due to Access denied. Can you try to enable pixel tracking for this mid.', + ); + }); + + it('should return a success message with status code 200 when the response status is 200 and no bad records or errors are found', () => { + const destinationResponse = { + response: '<response></response>', + status: 200, + }; + + const result = responseHandler(destinationResponse); + + expect(result.status).toBe(200); + expect(result.message).toBe('[RAKUTEN Response Handler] - Request Processed Successfully'); + expect(result.destinationResponse).toEqual(destinationResponse); + }); + + it('should throw a NetworkError with status code 400 and error message when the response status is 200 and the response contains only bad records', () => { + const destinationResponse = { + response: '<response><bad>1</bad></response>', + status: 200, + }; + + expect(() => { + responseHandler(destinationResponse); + }).toThrow('Request failed with status: 200 with number of bad records 1'); + + }); +}); diff --git a/test/integrations/destinations/rakuten/dataDelivery/data.ts b/test/integrations/destinations/rakuten/dataDelivery/data.ts new file mode 100644 index 00000000000..2d2b00a5e4e --- /dev/null +++ b/test/integrations/destinations/rakuten/dataDelivery/data.ts @@ -0,0 +1,203 @@ +import { endpoint, commonOutputHeaders } from '../processor/commonConfig'; +const commonParams = { + xml: 1, + amtlist: '12500|12500', + qlist: '|5', + ord: 'SampleOrderId', + tr: 'SampleRanSiteID', + land: '20240129_1200', +}; +export const data = [ + { + name: 'rakuten', + description: 'Test 0: Failure response from rakuten for invalid mid', + feature: 'dataDelivery', + module: 'destination', + scenario: 'Framework', + version: 'v0', + input: { + request: { + body: { + method: 'GET', + endpoint, + headers: commonOutputHeaders, + params: { + mid: 'invalid_mid', + ...commonParams, + }, + userId: '', + }, + }, + }, + output: { + response: { + status: 400, + statTags: { + errorCategory: 'network', + errorType: 'configuration', + destType: 'RAKUTEN', + module: 'destination', + implementation: 'native', + feature: 'dataDelivery', + destinationId: 'dummyDestId', + workspaceId: 'dummyWorkspaceId', + }, + destinationResponse: { + response: + '<!doctype html><html lang="en"><head><title>HTTP Status 400 – Bad Request</title></head><body><h1>HTTP Status 400 – Bad Request</h1></body></html>', + status: 400, + rudderJobMetadata: [ + { + jobId: 2, + attemptNum: 0, + userId: '', + sourceId: 'dummySourceId', + destinationId: 'dummyDestId', + workspaceId: 'dummyWorkspaceId', + }, + ], + }, + authErrorCategory: '', + message: 'Request failed with status: 400 due to invalid Marketing Id', + }, + }, + }, + { + name: 'rakuten', + description: 'Test 1: Failure response from rakuten for access denied for rakuten mid', + feature: 'dataDelivery', + scenario: 'Framework', + module: 'destination', + version: 'v0', + input: { + request: { + body: { + method: 'GET', + endpoint, + headers: commonOutputHeaders, + params: { + mid: 'access_denied_for_mid', + ...commonParams, + }, + userId: '', + }, + }, + }, + output: { + response: { + status: 400, + statTags: { + errorCategory: 'network', + errorType: 'configuration', + destType: 'RAKUTEN', + module: 'destination', + implementation: 'native', + feature: 'dataDelivery', + destinationId: 'dummyDestId', + workspaceId: 'dummyWorkspaceId', + }, + destinationResponse: { + response: '<response><error>Access denied</error></response>', + status: 200, + rudderJobMetadata: [ + { + jobId: 2, + attemptNum: 0, + userId: '', + sourceId: 'dummySourceId', + destinationId: 'dummyDestId', + workspaceId: 'dummyWorkspaceId', + }, + ], + }, + authErrorCategory: '', + message: + 'Request failed with status: 200 due to Access denied. Can you try to enable pixel tracking for this mid.', + }, + }, + }, + { + name: 'rakuten', + description: 'Test 2: Failure response from rakuten for bad records>0', + feature: 'dataDelivery', + scenario: 'Framework', + module: 'destination', + version: 'v0', + input: { + request: { + body: { + method: 'GET', + endpoint, + headers: commonOutputHeaders, + params: { + mid: 'valid_mid_with_bad_records', + ...commonParams, + }, + userId: '', + }, + }, + }, + output: { + response: { + status: 400, + statTags: { + errorCategory: 'network', + errorType: 'aborted', + destType: 'RAKUTEN', + module: 'destination', + implementation: 'native', + feature: 'dataDelivery', + destinationId: 'dummyDestId', + workspaceId: 'dummyWorkspaceId', + }, + destinationResponse: { + response: + '<response><unique_id>143407391431</unique_id><summary><transactions><good>0</good><bad>3</bad></transactions></summary></response>', + status: 200, + rudderJobMetadata: [ + { + jobId: 2, + attemptNum: 0, + userId: '', + sourceId: 'dummySourceId', + destinationId: 'dummyDestId', + workspaceId: 'dummyWorkspaceId', + }, + ], + }, + authErrorCategory: '', + message: 'Request failed with status: 200 with number of bad records 3', + }, + }, + }, + { + name: 'rakuten', + description: 'Test 3: Success response from rakuten with good records > 0', + feature: 'dataDelivery', + scenario: 'Framework', + module: 'destination', + version: 'v0', + input: { + request: { + body: { + method: 'GET', + endpoint, + headers: commonOutputHeaders, + params: { + mid: 'valid_mid_with_good_records', + ...commonParams, + }, + userId: '', + }, + }, + }, + output: { + response: { + status: 200, + destinationResponse: + '<response><unique_id>uniqueId</unique_id><summary><transactions><good>3</good><bad>0</bad></transactions></summary></response>', + message: '[RAKUTEN Response Handler] - Request Processed Successfully', + }, + }, + }, +]; diff --git a/test/integrations/destinations/rakuten/network.ts b/test/integrations/destinations/rakuten/network.ts new file mode 100644 index 00000000000..9633ee54a1a --- /dev/null +++ b/test/integrations/destinations/rakuten/network.ts @@ -0,0 +1,98 @@ +export const networkCallsData = [ + { + description: 'When mid is invalid', + httpReq: { + url: 'https://track.linksynergy.com/ep', + params: { + mid: 'invalid_mid', + xml: 1, + amtlist: '12500|12500', + qlist: '|5', + ord: 'SampleOrderId', + tr: 'SampleRanSiteID', + land: '20240129_1200', + }, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'GET', + }, + httpRes: { + status: 400, + data: '<!doctype html><html lang="en"><head><title>HTTP Status 400 – Bad Request</title></head><body><h1>HTTP Status 400 – Bad Request</h1></body></html>', + }, + }, + { + description: 'When mid is valid but there is no access', + httpReq: { + url: 'https://track.linksynergy.com/ep', + params: { + mid: 'access_denied_for_mid', + xml: 1, + amtlist: '12500|12500', + qlist: '|5', + ord: 'SampleOrderId', + tr: 'SampleRanSiteID', + land: '20240129_1200', + }, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'GET', + }, + httpRes: { + status: 200, + data: '<response><error>Access denied</error></response>', + }, + }, + { + description: 'When record along with mid is valid', + httpReq: { + url: 'https://track.linksynergy.com/ep', + params: { + mid: 'valid_mid_with_good_records', + xml: 1, + amtlist: '12500|12500', + qlist: '|5', + ord: 'SampleOrderId', + tr: 'SampleRanSiteID', + land: '20240129_1200', + }, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'GET', + }, + httpRes: { + status: 200, + data: '<response><unique_id>uniqueId</unique_id><summary><transactions><good>3</good><bad>0</bad></transactions></summary></response>', + }, + }, + { + description: 'When records are invalid and mid is valid', + httpReq: { + url: 'https://track.linksynergy.com/ep', + params: { + mid: 'valid_mid_with_bad_records', + xml: 1, + amtlist: '12500|12500', + qlist: '|5', + ord: 'SampleOrderId', + tr: 'SampleRanSiteID', + land: '20240129_1200', + }, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'GET', + }, + httpRes: { + status: 200, + data: '<response><unique_id>uniqueId</unique_id><summary><transactions><good>0</good><bad>3</bad></transactions></summary></response>', + }, + }, +]; diff --git a/test/integrations/destinations/rakuten/processor/commonConfig.ts b/test/integrations/destinations/rakuten/processor/commonConfig.ts new file mode 100644 index 00000000000..e7e2af7fbd1 --- /dev/null +++ b/test/integrations/destinations/rakuten/processor/commonConfig.ts @@ -0,0 +1,65 @@ +export const destination = { + ID: 'random_id', + Name: 'rakuten', + DestinationDefinition: { + Config: { + cdkV2Enabled: true, + }, + }, + Config: { + mid: 'dummyMarketingId', + }, +}; +export const endpoint = 'https://track.linksynergy.com/ep'; +export const commonOutputHeaders = { + accept: 'application/json', + 'content-type': 'application/json', +}; +export const singleProductWithAllProperties = { + sku: 'ABC123', + amount: 20, + quantity: 5, + name: 'SampleProduct', + brand: 'SampleBrand', + coupon: 'SALE20', + categoryId: '12345', + category: 'Electronics', + discountAmount: 10.5, + discountType: 'Percentage', + isClearance: 'Y', + isMarketPlace: 'N', + isSale: 'Y', + itmStatus: 'In Stock', + margin: 0.15, + markdown: 5.0, + shipId: 'SHIP123', + shipBy: 'Express', + taxExempt: 'N', + sequence: '123', + isComm: 'Y', +}; +export const commonProperties = { + orderId: 'SampleOrderId', + tr: 'SampleRanSiteID', + landTime: '20240129_1200', + date: '20240129_1300', + altord: 'SampleAlternateOrderId', + currency: 'INR', + creditCardType: 'Visa', + commReason: 'SampleCommReason', + isComm: 'Y', + consumed: '20240129_1400', + coupon: 'SampleCoupon', + custId: 'SampleCustomerId', + custScore: 'A', + custStatus: 'New', + dId: 'SampleDeviceId', + disamt: '50.00', + ordStatus: 'Pending', + segment: 'SampleSegment', + shipcountry: 'USA', + shipped: '20240129_1500', + sitename: 'SampleSiteName', + storeId: '12345', + storecat: 'Electronics', +}; diff --git a/test/integrations/destinations/rakuten/processor/data.ts b/test/integrations/destinations/rakuten/processor/data.ts new file mode 100644 index 00000000000..bdce4e850ec --- /dev/null +++ b/test/integrations/destinations/rakuten/processor/data.ts @@ -0,0 +1,3 @@ +import { transformationFailures } from './transformationFailure'; +import { trackSuccess } from './track'; +export const data = [...trackSuccess, ...transformationFailures]; diff --git a/test/integrations/destinations/rakuten/processor/track.ts b/test/integrations/destinations/rakuten/processor/track.ts new file mode 100644 index 00000000000..78a76e42632 --- /dev/null +++ b/test/integrations/destinations/rakuten/processor/track.ts @@ -0,0 +1,448 @@ +import { + destination, + commonOutputHeaders, + commonProperties, + endpoint, + singleProductWithAllProperties, +} from './commonConfig'; +import { transformResultBuilder } from '../../../testUtils'; +export const trackSuccess = [ + { + id: 'rakuten-test-track-success-1', + name: 'rakuten', + description: + 'Track call with properties.products with all properties in payload and one product containing all product properties and other containg some', + scenario: 'Business', + successCriteria: + 'Response should contain only properties and product payload and status code should be 200', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + destination, + message: { + type: 'track', + event: 'product purchased', + sentAt: '2021-01-25T16:12:02.048Z', + userId: 'sajal12', + channel: 'mobile', + rudderId: 'b7b24f86-f7bf-46d8-b2b4-ccafc080239c', + messageId: '1611588776408-ee5a3212-fbf9-4cbb-bbad-3ed0f7c6a2ce', + properties: { + ...commonProperties, + products: [ + { ...singleProductWithAllProperties }, + { + sku: 'custom sku 1', + quantity: 5, + amount: 25, + name: 'name_1', + }, + { + sku: 'custom sku 2', + name: 'SampleProduct', + quantity: 1, + amount: 30, + coupon: 'SALE50', + }, + ], + }, + anonymousId: '9c6bd77ea9da3e68', + integrations: { + All: true, + }, + originalTimestamp: '2021-01-25T15:32:56.409Z', + }, + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + output: transformResultBuilder({ + method: 'GET', + endpoint, + headers: commonOutputHeaders, + params: { + mid: 'dummyMarketingId', + xml: 1, + amtlist: '2000|2500|3000', + brandlist: 'SampleBrand||', + catidlist: '12345||', + catlist: 'Electronics||', + couponlist: 'SALE20||SALE50', + disamtlist: '10.5||', + distypelist: 'Percentage||', + ismarketplacelist: 'N||', + sequencelist: '123||', + shipbylist: 'Express||', + shipidlist: 'SHIP123||', + qlist: '5|5|1', + marginlist: '0.15||', + markdownlist: '5||', + taxexemptlist: 'N||', + namelist: 'SampleProduct|name_1|SampleProduct', + skulist: 'ABC123|custom sku 1|custom sku 2', + issalelist: 'Y||', + itmstatuslist: 'In Stock||', + isclearancelist: 'Y||', + ord: 'SampleOrderId', + tr: 'SampleRanSiteID', + land: '20240129_1200', + date: '20240129_1300', + altord: 'SampleAlternateOrderId', + cur: 'INR', + cc: 'Visa', + commreason: 'SampleCommReason', + iscomm: 'Y', + consumed: '20240129_1400', + coupon: 'SampleCoupon', + custid: 'SampleCustomerId', + custscore: 'A', + custstatus: 'New', + did: 'SampleDeviceId', + disamt: '50.00', + ordstatus: 'Pending', + segment: 'SampleSegment', + shipcountry: 'USA', + shipped: '20240129_1500', + sitename: 'SampleSiteName', + storeid: '12345', + storecat: 'Electronics', + }, + userId: '', + }), + statusCode: 200, + }, + ], + }, + }, + }, + { + id: 'rakuten-test-track-success-2', + name: 'rakuten', + description: + 'Track call with properties.products and no event in payload and products containing amount,price and quantity', + scenario: 'Business+Framework', + successCriteria: + 'Response should contain only properties and product payload and amount to be calculated from price*quantity where amount is not present and quantity taken as 1 by default and status code should be 200', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + destination, + message: { + type: 'track', + sentAt: '2021-01-25T16:12:02.048Z', + userId: 'sajal12', + channel: 'mobile', + rudderId: 'b7b24f86-f7bf-46d8-b2b4-ccafc080239c', + messageId: '1611588776408-ee5a3212-fbf9-4cbb-bbad-3ed0f7c6a2ce', + properties: { + ...commonProperties, + products: [ + { + sku: 'custom sku 0', + amount: '125', + quantity: 1, + name: 'name_1', + }, + { + sku: 'custom sku 1', + quantity: 5, + price: 25, + name: 'name_2', + }, + { + sku: 'custom sku 2', + name: 'SampleProduct', + price: 30, + quantity: 1, + coupon: 'SALE50', + }, + ], + }, + anonymousId: '9c6bd77ea9da3e68', + integrations: { + All: true, + }, + originalTimestamp: '2021-01-25T15:32:56.409Z', + }, + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + output: transformResultBuilder({ + method: 'GET', + endpoint, + headers: commonOutputHeaders, + params: { + mid: 'dummyMarketingId', + xml: 1, + amtlist: '12500|12500|3000', + couponlist: '||SALE50', + namelist: 'name_1|name_2|SampleProduct', + skulist: 'custom sku 0|custom sku 1|custom sku 2', + qlist: '1|5|1', + ord: 'SampleOrderId', + tr: 'SampleRanSiteID', + land: '20240129_1200', + date: '20240129_1300', + altord: 'SampleAlternateOrderId', + cur: 'INR', + cc: 'Visa', + commreason: 'SampleCommReason', + iscomm: 'Y', + consumed: '20240129_1400', + coupon: 'SampleCoupon', + custid: 'SampleCustomerId', + custscore: 'A', + custstatus: 'New', + did: 'SampleDeviceId', + disamt: '50.00', + ordstatus: 'Pending', + segment: 'SampleSegment', + shipcountry: 'USA', + shipped: '20240129_1500', + sitename: 'SampleSiteName', + storeid: '12345', + storecat: 'Electronics', + }, + userId: '', + }), + statusCode: 200, + }, + ], + }, + }, + }, + { + id: 'rakuten-test-track-success-3', + name: 'rakuten', + description: + 'Track call for products return or cancelled products containing amount,price and quantity where price is negative', + scenario: 'Business', + successCriteria: + 'Response should contain only properties and product payload and amount to be calculated from price*quantity where amount is negative and status code should be 200', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + destination, + message: { + type: 'track', + sentAt: '2021-01-25T16:12:02.048Z', + userId: 'sajal12', + channel: 'mobile', + rudderId: 'b7b24f86-f7bf-46d8-b2b4-ccafc080239c', + messageId: '1611588776408-ee5a3212-fbf9-4cbb-bbad-3ed0f7c6a2ce', + properties: { + ...commonProperties, + products: [ + { + sku: 'custom sku 0', + quantity: 1, + amount: '-125', + name: 'name_1', + }, + { + sku: 'custom sku 1', + quantity: 5, + price: -25, + name: 'name_2', + }, + ], + }, + anonymousId: '9c6bd77ea9da3e68', + integrations: { + All: true, + }, + originalTimestamp: '2021-01-25T15:32:56.409Z', + }, + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + output: transformResultBuilder({ + method: 'GET', + endpoint, + headers: commonOutputHeaders, + params: { + mid: 'dummyMarketingId', + xml: 1, + amtlist: '-12500|-12500', + skulist: 'custom sku 0|custom sku 1', + qlist: '1|5', + ord: 'SampleOrderId', + namelist: 'name_1|name_2', + tr: 'SampleRanSiteID', + land: '20240129_1200', + date: '20240129_1300', + altord: 'SampleAlternateOrderId', + cur: 'INR', + cc: 'Visa', + commreason: 'SampleCommReason', + iscomm: 'Y', + consumed: '20240129_1400', + coupon: 'SampleCoupon', + custid: 'SampleCustomerId', + custscore: 'A', + custstatus: 'New', + did: 'SampleDeviceId', + disamt: '50.00', + ordstatus: 'Pending', + segment: 'SampleSegment', + shipcountry: 'USA', + shipped: '20240129_1500', + sitename: 'SampleSiteName', + storeid: '12345', + storecat: 'Electronics', + }, + userId: '', + }), + statusCode: 200, + }, + ], + }, + }, + }, + { + id: 'rakuten-test-track-success-4', + name: 'rakuten', + description: 'Track call for Discount event ', + scenario: 'Business', + successCriteria: + 'Response should have last item of skulist as "Discount", qlist as 0 and amtlist as negative and status code should be 200', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + destination, + message: { + type: 'track', + sentAt: '2021-01-25T16:12:02.048Z', + userId: 'sajal12', + channel: 'mobile', + rudderId: 'b7b24f86-f7bf-46d8-b2b4-ccafc080239c', + messageId: '1611588776408-ee5a3212-fbf9-4cbb-bbad-3ed0f7c6a2ce', + properties: { + orderId: 'SampleOrderId', + tr: 'SampleRanSiteID', + landTime: '20240129_1200', + products: [ + { + sku: 'custom sku 0', + quantity: 5, + amount: '125', + name: 'name_1', + }, + { + sku: 'custom sku 1', + quantity: 5, + price: 25, + name: 'name_2', + }, + { + sku: 'Discount', + quantity: 0, + amount: -500, + name: 'Discount', + }, + ], + }, + anonymousId: '9c6bd77ea9da3e68', + integrations: { + All: true, + }, + originalTimestamp: '2021-01-25T15:32:56.409Z', + }, + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + output: transformResultBuilder({ + method: 'GET', + endpoint, + headers: commonOutputHeaders, + params: { + mid: 'dummyMarketingId', + xml: 1, + namelist: 'name_1|name_2|Discount', + amtlist: '12500|12500|-50000', + skulist: 'custom sku 0|custom sku 1|Discount', + qlist: '5|5|0', + ord: 'SampleOrderId', + tr: 'SampleRanSiteID', + land: '20240129_1200', + }, + userId: '', + }), + statusCode: 200, + }, + ], + }, + }, + }, +]; diff --git a/test/integrations/destinations/rakuten/processor/transformationFailure.ts b/test/integrations/destinations/rakuten/processor/transformationFailure.ts new file mode 100644 index 00000000000..906ddafd6ac --- /dev/null +++ b/test/integrations/destinations/rakuten/processor/transformationFailure.ts @@ -0,0 +1,335 @@ +import { destination } from './commonConfig'; + +export const transformationFailures = [ + { + id: 'rakuten-test-2', + name: 'rakuten', + description: 'Required field orderId not present', + scenario: 'Framework', + successCriteria: 'Transformationn Error for orderId not present', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + destination, + message: { + type: 'track', + event: 'product purchased', + sentAt: '2021-01-25T16:12:02.048Z', + userId: 'sajal12', + channel: 'mobile', + rudderId: 'b7b24f86-f7bf-46d8-b2b4-ccafc080239c', + messageId: '1611588776408-ee5a3212-fbf9-4cbb-bbad-3ed0f7c6a2ce', + properties: { + products: [{}], + }, + anonymousId: '9c6bd77ea9da3e68', + integrations: { + All: true, + }, + originalTimestamp: '2021-01-25T15:32:56.409Z', + }, + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + error: + 'Missing required value from "properties.orderId": Workflow: procWorkflow, Step: prepareTrackPayload, ChildStep: undefined, OriginalError: Missing required value from "properties.orderId"', + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + statTags: { + destType: 'RAKUTEN', + destinationId: 'dummyDestId', + errorCategory: 'dataValidation', + errorType: 'instrumentation', + feature: 'processor', + implementation: 'cdkV2', + module: 'destination', + }, + statusCode: 400, + }, + ], + }, + }, + }, + { + id: 'rakuten-test-3', + name: 'rakuten', + description: 'No products available in products array to send', + scenario: 'Framework', + successCriteria: 'Transformationn Error for no products present to send', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + destination, + message: { + type: 'track', + event: 'product purchased', + sentAt: '2021-01-25T16:12:02.048Z', + userId: 'sajal12', + channel: 'mobile', + rudderId: 'b7b24f86-f7bf-46d8-b2b4-ccafc080239c', + messageId: '1611588776408-ee5a3212-fbf9-4cbb-bbad-3ed0f7c6a2ce', + properties: { + land: '20230406_2342', + tr: 'txnId', + orderId: 'ord 123', + products: [], + }, + anonymousId: '9c6bd77ea9da3e68', + integrations: { + All: true, + }, + originalTimestamp: '2021-01-25T15:32:56.409Z', + }, + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + error: + 'Either properties.product is not an array or is empty: Workflow: procWorkflow, Step: prepareTrackPayload, ChildStep: undefined, OriginalError: Either properties.product is not an array or is empty', + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + statTags: { + destType: 'RAKUTEN', + destinationId: 'dummyDestId', + errorCategory: 'dataValidation', + errorType: 'instrumentation', + feature: 'processor', + implementation: 'cdkV2', + module: 'destination', + }, + statusCode: 400, + }, + ], + }, + }, + }, + { + id: 'rakuten-test-4', + name: 'rakuten', + description: 'Unsupported message type -> Identify', + scenario: 'Framework', + successCriteria: 'Transformationn Error for Unsupported message type', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + destination, + message: { + type: 'identify', + sentAt: '2021-01-25T16:12:02.048Z', + userId: 'sajal12', + channel: 'mobile', + rudderId: 'b7b24f86-f7bf-46d8-b2b4-ccafc080239c', + messageId: '1611588776408-ee5a3212-fbf9-4cbb-bbad-3ed0f7c6a2ce', + traits: { + orderId: 'ord 123', + products: [], + }, + anonymousId: '9c6bd77ea9da3e68', + integrations: { + All: true, + }, + originalTimestamp: '2021-01-25T15:32:56.409Z', + }, + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + error: + 'message type identify is not supported: Workflow: procWorkflow, Step: validateInput, ChildStep: undefined, OriginalError: message type identify is not supported', + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + statTags: { + destType: 'RAKUTEN', + errorCategory: 'dataValidation', + destinationId: 'dummyDestId', + errorType: 'instrumentation', + feature: 'processor', + implementation: 'cdkV2', + module: 'destination', + }, + statusCode: 400, + }, + ], + }, + }, + }, + { + id: 'rakuten-test-5', + name: 'rakuten', + description: 'No eligible property available for required field tr present', + scenario: 'Framework', + successCriteria: 'Transformationn Error for required field tr not present', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + destination, + message: { + type: 'track', + event: 'product purchased', + sentAt: '2021-01-25T16:12:02.048Z', + userId: 'sajal12', + channel: 'mobile', + rudderId: 'b7b24f86-f7bf-46d8-b2b4-ccafc080239c', + messageId: '1611588776408-ee5a3212-fbf9-4cbb-bbad-3ed0f7c6a2ce', + properties: { + orderId: 'ord 123', + products: [], + }, + anonymousId: '9c6bd77ea9da3e68', + integrations: { + All: true, + }, + originalTimestamp: '2021-01-25T15:32:56.409Z', + }, + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + error: + 'Missing required value from ["properties.tr","properties.ranSiteID"]: Workflow: procWorkflow, Step: prepareTrackPayload, ChildStep: undefined, OriginalError: Missing required value from ["properties.tr","properties.ranSiteID"]', + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + statTags: { + destType: 'RAKUTEN', + destinationId: 'dummyDestId', + errorCategory: 'dataValidation', + errorType: 'instrumentation', + feature: 'processor', + implementation: 'cdkV2', + module: 'destination', + }, + statusCode: 400, + }, + ], + }, + }, + }, + { + id: 'rakuten-test-6', + name: 'rakuten', + description: 'No eligible property available for required field land present', + scenario: 'Framework', + successCriteria: 'Transformationn Error for required field land not present', + feature: 'processor', + module: 'destination', + version: 'v0', + input: { + request: { + body: [ + { + destination, + message: { + type: 'track', + event: 'product purchased', + sentAt: '2021-01-25T16:12:02.048Z', + userId: 'sajal12', + channel: 'mobile', + rudderId: 'b7b24f86-f7bf-46d8-b2b4-ccafc080239c', + messageId: '1611588776408-ee5a3212-fbf9-4cbb-bbad-3ed0f7c6a2ce', + properties: { + tr: 'txnId', + orderId: 'ord 123', + products: [], + }, + anonymousId: '9c6bd77ea9da3e68', + integrations: { + All: true, + }, + originalTimestamp: '2021-01-25T15:32:56.409Z', + }, + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + }, + ], + }, + }, + output: { + response: { + status: 200, + body: [ + { + error: + 'Missing required value from ["properties.land","properties.landTime"]: Workflow: procWorkflow, Step: prepareTrackPayload, ChildStep: undefined, OriginalError: Missing required value from ["properties.land","properties.landTime"]', + metadata: { + destinationId: 'dummyDestId', + jobId: '1', + }, + statTags: { + destType: 'RAKUTEN', + destinationId: 'dummyDestId', + errorCategory: 'dataValidation', + errorType: 'instrumentation', + feature: 'processor', + implementation: 'cdkV2', + module: 'destination', + }, + statusCode: 400, + }, + ], + }, + }, + }, +]; diff --git a/test/integrations/testUtils.ts b/test/integrations/testUtils.ts index 09f3a82d40b..1eb1f692aa5 100644 --- a/test/integrations/testUtils.ts +++ b/test/integrations/testUtils.ts @@ -38,7 +38,7 @@ export const getAllTestMockDataFilePaths = (dirPath: string, destination: string }; export const addMock = (mock: MockAdapter, axiosMock: MockHttpCallsData) => { - const { url, method, data: reqData, ...opts } = axiosMock.httpReq; + const { url, method, data: reqData, params, ...opts } = axiosMock.httpReq; const { data, headers, status } = axiosMock.httpRes; const headersAsymMatch = { @@ -49,8 +49,10 @@ export const addMock = (mock: MockAdapter, axiosMock: MockHttpCallsData) => { switch (method.toLowerCase()) { case 'get': + // We are accepting parameters exclusively for mocking purposes and do not require a request body, + // particularly for GET requests where it is typically unnecessary // @ts-ignore - mock.onGet(url, reqData, headersAsymMatch).reply(status, data, headers); + mock.onGet(url, { params }, headersAsymMatch).reply(status, data, headers); break; case 'delete': // @ts-ignore