mcdev
Version:
Accenture Salesforce Marketing Cloud DevTools
488 lines (457 loc) • 19.7 kB
JavaScript
import fs from 'fs-extra';
import path from 'node:path';
import { XMLParser } from 'fast-xml-parser';
import { Util } from '../lib/util/util.js';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRootHelper = __dirname.split(path.sep);
projectRootHelper.pop();
const projectRoot = projectRootHelper.join(path.sep) + path.sep;
const parser = new XMLParser();
const attributeParser = new XMLParser({ ignoreAttributes: false });
/** @type {typeof Util.color} */
const color = Util.color;
export const tWarn = `${color.bgYellow}${color.fgBlack}TEST-WARNING${color.reset}`;
export const tError = `${color.bgRed}${color.fgBlack}TEST-ERROR${color.reset}`;
const loadingFile = 'loading server file:///';
/**
* gets mock SOAP metadata for responding
*
* @param {string} mcdevAction SOAP action
* @param {string} type metadata Type
* @param {string} mid of Business Unit
* @param {object|string} filter likely for customer key
* @param {boolean} [QueryAllAccounts] get data from other BUs or not
* @returns {Promise.<string>} relevant metadata stringified
*/
async function loadSOAPRecords(mcdevAction, type, mid, filter, QueryAllAccounts) {
type = type[0].toLowerCase() + type.slice(1);
const testPath = path.join('test', 'resources', mid.toString(), type, mcdevAction);
const filterPath = getFilterPath(filter, QueryAllAccounts);
if (await fs.pathExists(testPath + filterPath + '-response.xml')) {
console.log(loadingFile + projectRoot + testPath + filterPath + '-response.xml'); // eslint-disable-line no-console
return fs.readFile(testPath + filterPath + '-response.xml', {
encoding: 'utf8',
});
} else if (await fs.pathExists(testPath + '-response.xml')) {
if (filterPath) {
/* eslint-disable no-console */
console.log(
`${tWarn}: You are loading your reponse from ${
testPath + '-response.xml'
} instead of the more specific ${
testPath + filterPath + '-response.xml'
}. Make sure this is intended`
);
/* eslint-enable no-console */
}
console.log(loadingFile + projectRoot + testPath + '-response.xml'); // eslint-disable-line no-console
return fs.readFile(testPath + '-response.xml', {
encoding: 'utf8',
});
}
/* eslint-disable no-console */
console.log(
`${tError}: Please create file ${
filterPath ? testPath + filterPath + '-response.xml or ' : ''
}${testPath + '-response.xml'}`
);
/* eslint-enable no-console */
// return error
process.exitCode = 404;
return fs.readFile(path.join('test', 'resources', mcdevAction + '-response.xml'), {
encoding: 'utf8',
});
}
/**
* helper for {@link loadSOAPRecords} to get the filter path
*
* @param {object|string} filter likely for customer key
* @param {boolean} [QueryAllAccounts] get data from other BUs or not
* @param {number} [shorten] number of characters to shorten filters by to match windows max file length of 256 chars
* @returns {string} filterPath value
*/
function getFilterPath(filter, QueryAllAccounts, shorten) {
const filterPath =
(typeof filter === 'string' && filter ? '-' + filter : filterToPath(filter, shorten)) +
(QueryAllAccounts ? '-QAA' : '');
if ((filterPath + '-response.xml').length > 256) {
shorten ||= 10;
return getFilterPath(filter, QueryAllAccounts, --shorten);
} else {
return filterPath;
}
}
/**
* main filter to path function
*
* @param {object} filter main filter object
* @param {string} filter.Property field name
* @param {string} filter.SimpleOperator string representation of the comparison method
* @param {string} filter.Value field value to check for
* @param {object} filter.LeftOperand contains a filter object itself
* @param {'AND'|'OR'} filter.LogicalOperator string representation of the comparison method
* @param {object} filter.RightOperand field value to check for
* @param {number} [shorten] number of characters to shorten filters by to match windows max file length of 256 chars
* @returns {string} string represenation of the entire filter
*/
export function filterToPath(filter, shorten) {
if (filter) {
return '-' + _filterToPath(filter, shorten);
}
return '';
}
/**
* helper for filterToPath
*
* @param {object} filter main filter object
* @param {string} filter.Property field name
* @param {string} filter.SimpleOperator string representation of the comparison method
* @param {string} filter.Value field value to check for
* @param {object} filter.LeftOperand contains a filter object itself
* @param {'AND'|'OR'} filter.LogicalOperator string representation of the comparison method
* @param {object} filter.RightOperand field value to check for
* @param {number} [shorten] number of characters to shorten filters by to match windows max file length of 256 chars
* @returns {string} string represenation of the entire filter
*/
function _filterToPath(filter, shorten) {
if (filter.Property && filter.SimpleOperator) {
let value;
if (filter.Value === undefined) {
value = '';
} else if (Array.isArray(filter.Value)) {
value = shorten
? filter.Value.map((val) => val.slice(0, Math.max(0, shorten))).join(',')
: filter.Value.join(',');
} else {
value = shorten ? filter.Value.slice(0, Math.max(0, shorten)) : filter.Value;
}
return `${filter.Property}${filter.SimpleOperator.replace('equals', '=')}${value}`;
} else if (filter.LeftOperand && filter.LogicalOperator && filter.RightOperand) {
return (
_filterToPath(filter.LeftOperand, shorten) +
filter.LogicalOperator +
_filterToPath(filter.RightOperand, shorten)
);
} else {
throw new Error('unknown filter type');
}
}
/**
* based on request, respond with different soap data
*
* @param {object} config mock api request object
* @returns {Promise.<Array>} status code plus response in string form
*/
export const handleSOAPRequest = async (config) => {
const jObj = parser.parse(config.data);
const fullObj = attributeParser.parse(config.data);
let responseXML;
switch (config.headers.SOAPAction) {
case 'Retrieve': {
responseXML = await loadSOAPRecords(
config.headers.SOAPAction.toLocaleLowerCase(),
jObj.Envelope.Body.RetrieveRequestMsg.RetrieveRequest.ObjectType,
jObj.Envelope.Header.fueloauth,
jObj.Envelope.Body.RetrieveRequestMsg.RetrieveRequest.Filter,
jObj.Envelope.Body.RetrieveRequestMsg.RetrieveRequest.QueryAllAccounts
);
break;
}
case 'Create': {
let filter = null;
if (fullObj.Envelope.Body.CreateRequest.Objects['@_xsi:type'] === 'DataFolder') {
filter = `ContentType=${fullObj.Envelope.Body.CreateRequest.Objects.ContentType},Name=${fullObj.Envelope.Body.CreateRequest.Objects.Name},ParentFolderID=${fullObj.Envelope.Body.CreateRequest.Objects.ParentFolder.ID}`;
}
responseXML = await loadSOAPRecords(
config.headers.SOAPAction.toLocaleLowerCase(),
fullObj.Envelope.Body.CreateRequest.Objects['@_xsi:type'],
jObj.Envelope.Header.fueloauth,
filter
);
break;
}
case 'Update': {
responseXML = await loadSOAPRecords(
config.headers.SOAPAction.toLocaleLowerCase(),
fullObj.Envelope.Body.UpdateRequest.Objects['@_xsi:type'],
jObj.Envelope.Header.fueloauth,
null
);
break;
}
case 'Configure': {
responseXML = await loadSOAPRecords(
config.headers.SOAPAction.toLocaleLowerCase(),
fullObj.Envelope.Body.ConfigureRequestMsg.Configurations.Configuration[0][
'@_xsi:type'
],
jObj.Envelope.Header.fueloauth,
null
);
break;
}
case 'Delete': {
responseXML = await loadSOAPRecords(
config.headers.SOAPAction.toLocaleLowerCase(),
fullObj.Envelope.Body.DeleteRequest.Objects['@_xsi:type'],
jObj.Envelope.Header.fueloauth,
null
);
break;
}
case 'Schedule': {
responseXML = await loadSOAPRecords(
config.headers.SOAPAction.toLocaleLowerCase(),
fullObj.Envelope.Body.ScheduleRequestMsg.Interactions.Interaction['@_xsi:type'],
jObj.Envelope.Header.fueloauth,
fullObj.Envelope.Body.ScheduleRequestMsg.Interactions.Interaction.ObjectID
);
break;
}
case 'Perform': {
responseXML = await loadSOAPRecords(
config.headers.SOAPAction.toLocaleLowerCase(),
fullObj.Envelope.Body.PerformRequestMsg.Definitions.Definition['@_xsi:type'],
jObj.Envelope.Header.fueloauth,
fullObj.Envelope.Body.PerformRequestMsg.Definitions.Definition.ObjectID
);
break;
}
default: {
throw new Error(
`The SOAP Action ${config.headers.SOAPAction} is not supported by test handler`
);
}
}
return [200, responseXML];
};
/**
* helper to return soap base URL
*
* @returns {string} soap URL
*/
export const soapUrl =
'https://mct0l7nxfq2r988t1kxfy8sc4xxx.soap.marketingcloudapis.com/Service.asmx';
/**
* based on request, respond with different soap data
*
* @param {object} config mock api request object
* @returns {Promise.<Array>} status code plus response in string form
*/
export const handleRESTRequest = async (config) => {
try {
// check if filtered
const urlObj = new URL(
config.baseURL + (config.url.startsWith('/') ? config.url.slice(1) : config.url)
);
let filterName;
let filterBody;
if (urlObj.searchParams.get('$filter')) {
filterName = urlObj.searchParams.get('$filter').split(' eq ')[1];
} else if (urlObj.searchParams.get('action')) {
filterName = urlObj.searchParams.get('action');
} else if (urlObj.searchParams.get('mostRecentVersionOnly')) {
filterName = 'mostRecentVersionOnly';
} else if (urlObj.searchParams.get('versionNumber')) {
filterName = 'versionNumber';
} else if (urlObj.searchParams.get('id')) {
filterName = 'id';
}
const testPath = path
.join(
'test',
'resources',
config.headers.Authorization.replace('Bearer ', ''),
urlObj.pathname,
config.method + '-response'
)
.replace(':', '_'); // replace : with _ for Windows
const testPathFilter = filterName
? testPath +
'-' +
(urlObj.searchParams.get('$filter') || urlObj.searchParams.get('action') || '')
.replaceAll(' eq ', '=')
.replaceAll(' ', '') +
(urlObj.searchParams.get('id') ? 'id=' + urlObj.searchParams.get('id') : '') +
(urlObj.searchParams.get('versionNumber')
? 'versionNumber=' + urlObj.searchParams.get('versionNumber')
: '') +
(urlObj.searchParams.get('mostRecentVersionOnly')
? 'mostRecentVersionOnly=' + urlObj.searchParams.get('mostRecentVersionOnly')
: '')
: null;
if (!testPathFilter && config.method === 'post' && config.data) {
const simpleOperators = { equal: '=', in: 'IN' };
const data = JSON.parse(config.data);
const myObj = data.query?.rightOperand || data.query;
if (myObj) {
const op = simpleOperators[myObj.simpleOperator];
filterBody = `${myObj.property}${op}${op === 'IN' ? myObj.value.join(',') : myObj.value}`;
} else if (config.url === '/email/v1/category') {
const data = JSON.parse(config.data);
filterBody = Object.keys(data)
.map((key) => `${key}=${data[key]}`)
.join(',');
} else if (config.url === '/asset/v1/content/assets/') {
const data = JSON.parse(config.data);
if (data.customerKey) {
filterBody = 'key=' + data.customerKey;
}
}
}
const testPathFilterBody = filterBody ? testPath + '-' + filterBody : null;
if (testPathFilter && (await fs.pathExists(testPathFilter + '.json'))) {
// build filter logic to ensure templating works
if (filterName) {
const response = JSON.parse(
await fs.readFile(testPathFilter + '.json', {
encoding: 'utf8',
})
);
if (
response.items &&
filterName !== 'mostRecentVersionOnly' &&
filterName !== 'versionNumber' &&
filterName !== 'id'
) {
response.items = response.items.filter((def) => def.name == filterName);
}
console.log(loadingFile + projectRoot + testPathFilter + '.json'); // eslint-disable-line no-console
return [200, JSON.stringify(response)];
} else {
console.log(loadingFile + projectRoot + testPathFilter + '.json'); // eslint-disable-line no-console
return [
200,
await fs.readFile(testPathFilter + '.json', {
encoding: 'utf8',
}),
];
}
} else if (testPathFilter && (await fs.pathExists(testPathFilter + '.txt'))) {
console.log(loadingFile + projectRoot + testPathFilter + '.txt'); // eslint-disable-line no-console
return [
200,
await fs.readFile(testPathFilter + '.txt', {
encoding: 'utf8',
}),
];
} else if (testPathFilterBody && (await fs.pathExists(testPathFilterBody + '.json'))) {
console.log(loadingFile + projectRoot + testPathFilterBody + '.json'); // eslint-disable-line no-console
return [
200,
await fs.readFile(testPathFilterBody + '.json', {
encoding: 'utf8',
}),
];
} else if (testPathFilterBody && (await fs.pathExists(testPathFilterBody + '.txt'))) {
console.log(loadingFile + projectRoot + testPathFilterBody + '.txt'); // eslint-disable-line no-console
return [
200,
await fs.readFile(testPathFilterBody + '.txt', {
encoding: 'utf8',
}),
];
} else if (await fs.pathExists(testPath + '.json')) {
if (testPathFilter) {
/* eslint-disable no-console */
console.log(
`${tWarn}: You are loading your reponse from ${
testPath + '.json'
} instead of the more specific ${
testPathFilter + '.json'
}. Make sure this is intended`
);
/* eslint-enable no-console */
}
if (testPathFilterBody) {
/* eslint-disable no-console */
console.log(
`${tWarn}: You are loading your reponse from ${
testPath + '.json'
} instead of the more specific ${
testPathFilterBody + '.json'
}. Make sure this is intended`
);
/* eslint-enable no-console */
}
// build filter logic to ensure templating works
if (
filterName &&
filterName !== 'mostRecentVersionOnly' &&
filterName !== 'versionNumber' &&
filterName !== 'id'
) {
const response = JSON.parse(
await fs.readFile(testPath + '.json', {
encoding: 'utf8',
})
);
response.items = response.items.filter((def) => def.name == filterName);
response.count = response.items.length;
console.log(loadingFile + projectRoot + testPath + '.json'); // eslint-disable-line no-console
return [200, JSON.stringify(response)];
} else {
console.log(loadingFile + projectRoot + testPath + '.json'); // eslint-disable-line no-console
return [
200,
await fs.readFile(testPath + '.json', {
encoding: 'utf8',
}),
];
}
} else if (await fs.pathExists(testPath + '.txt')) {
if (testPathFilter) {
/* eslint-disable no-console */
console.log(
`${tWarn}: You are loading your reponse from ${
testPath + '.txt'
} instead of the more specific ${
testPathFilter + '.txt'
}. Make sure this is intended`
);
/* eslint-enable no-console */
}
if (testPathFilterBody) {
/* eslint-disable no-console */
console.log(
`${tWarn}: You are loading your reponse from ${
testPath + '.txt'
} instead of the more specific ${
testPathFilterBody + '.txt'
}. Make sure this is intended`
);
/* eslint-enable no-console */
}
console.log(loadingFile + projectRoot + testPath + '.txt'); // eslint-disable-line no-console
return [
200,
await fs.readFile(testPath + '.txt', {
encoding: 'utf8',
}),
];
} else {
/* eslint-disable no-console */
console.log(
`${tError}: Please create file ${testPath}.json/.txt${filterName ? ` or ${testPathFilter}.json/.txt` : testPathFilterBody ? ` or ${testPathFilterBody}.json/.txt` : ''}`
);
/* eslint-enable no-console */
process.exitCode = 404;
return [
404,
await fs.readFile(path.join('test', 'resources', 'rest404-response.json'), {
encoding: 'utf8',
}),
];
}
} catch (ex) {
console.log(ex); // eslint-disable-line no-console
return [500, {}];
}
};
/**
* helper to return rest base URL
*
* @returns {string} test URL
*/
export const restUrl = 'https://mct0l7nxfq2r988t1kxfy8sc4xxx.rest.marketingcloudapis.com/';