UNPKG

mcdev

Version:

Accenture Salesforce Marketing Cloud DevTools

419 lines (388 loc) 15 kB
import File from '../lib/util/file.js'; import path from 'node:path'; import MockAdapter from 'axios-mock-adapter'; import { axiosInstance } from '../node_modules/sfmc-sdk/lib/util.js'; import handler from '../lib/index.js'; import auth from '../lib/util/auth.js'; import { Util } from '../lib/util/util.js'; import cache from '../lib/util/cache.js'; import ReplaceContentBlockReference from '../lib/util/replaceContentBlockReference.js'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // for some reason doesnt realize below reference import fsmock from 'mock-fs'; let apimock; import { handleSOAPRequest, handleRESTRequest, soapUrl, restUrl, tWarn, } from './resourceFactory.js'; const authResources = File.readJsonSync(path.join(__dirname, './resources/auth.json')); const loadingFile = 'loading expected file:///' + __dirname.split(path.sep).join('/'); /** * gets file from Retrieve folder * * @param {string} from source path (starting in bu folder) * @param {string} to target path (starting in bu folder) * @param {string} [mid] used when we need to test on ParentBU * @returns {Promise.<{status:'ok'|'skipped'|'failed', statusMessage:string, file:string}>} - */ export async function copyFile(from, to, mid = '9999999') { return File.copyFileSimple(`./test/resources/${mid}/${from}`, `./test/resources/${mid}/${to}`); } /** * gets file from Retrieve folder * * @param {string} from source path (starting in bu folder) * @param {string} to target path (starting in bu folder) * @param {string} [mid] used when we need to test on ParentBU * @param {string} [buName] used when we need to test on ParentBU * @returns {void} - */ export function copyToDeploy(from, to, mid = '9999999', buName = 'testBU') { console.log(`Copying ${from} to deploy folder`); // eslint-disable-line no-console File.copySync(`./test/resources/${mid}/${from}`, `./deploy/testInstance/${buName}/${to}`); } /** * gets file from Retrieve folder * * @param {string} customerKey of metadata * @param {string} type of metadata * @param {string} [buName] used when we need to test on ParentBU * @returns {Promise.<string>} file in string form */ export function getActualJson(customerKey, type, buName = 'testBU') { return File.readJSON( `./retrieve/testInstance/${buName}/${type}/${customerKey}.${type}-meta.json` ); } /** * gets file from Retrieve folder * * @param {string} customerKey of metadata * @param {string} type of metadata * @param {string} [buName] used when we need to test on ParentBU * @returns {Promise.<string>} file path */ export function getActualDoc(customerKey, type, buName = 'testBU') { return File.readFile( `./retrieve/testInstance/${buName}/${type}/${customerKey}.${type}-doc.md`, 'utf8' ); } /** * gets file from Retrieve folder * * @param {string} customerKey of metadata * @param {string} type of metadata * @param {string} ext file extension * @param {string} [buName] used when we need to test on ParentBU * @returns {Promise.<string | null>} file in string form, null if not found */ export async function getActualFile(customerKey, type, ext, buName = 'testBU') { const path = `./retrieve/testInstance/${buName}/${type}/${customerKey}.${type}-meta.${ext}`; try { return await File.readFile(path, 'utf8'); } catch { console.log(`File not found: ${path}`); // eslint-disable-line no-console return null; } } /** * gets file from Deploy folder * * @param {string} customerKey of metadata * @param {string} type of metadata * @param {string} [buName] used when we need to test on ParentBU * @returns {Promise.<string>} file in JSON form */ export function getActualDeployJson(customerKey, type, buName = 'testBU') { return File.readJSON( `./deploy/testInstance/${buName}/${type}/${customerKey}.${type}-meta.json` ); } /** * gets file from Deploy folder * * @param {string} customerKey of metadata * @param {string} type of metadata * @param {string} ext file extension * @param {string} [buName] used when we need to test on ParentBU * @returns {Promise.<string>} file in string form */ export function getActualDeployFile(customerKey, type, ext, buName = 'testBU') { return File.readFile( `./deploy/testInstance/${buName}/${type}/${customerKey}.${type}-meta.${ext}`, 'utf8' ); } /** * gets file from Template folder * * @param {string} customerKey of metadata * @param {string} type of metadata * @returns {Promise.<string>} file in JSON form */ export function getActualTemplateJson(customerKey, type) { return File.readJSON(`./template/${type}/${customerKey}.${type}-meta.json`); } /** * gets file from Template folder * * @param {string} customerKey of metadata * @param {string} type of metadata * @param {string} ext file extension * @returns {Promise.<string>} file in string form */ export function getActualTemplateFile(customerKey, type, ext) { return File.readFile(`./template/${type}/${customerKey}.${type}-meta.${ext}`, 'utf8'); } /** * gets file from resources folder which should be used for comparison * * @param {string} mid of Business Unit * @param {string} type of metadata * @param {string} action of SOAP request * @returns {Promise.<string>} file in JSON form */ export function getExpectedJson(mid, type, action) { const path = `/resources/${mid}/${type}/${action}-expected.json`; console.log(loadingFile + path); // eslint-disable-line no-console return File.readJSON(`./test` + path); } /** * gets file from resources folder which should be used for comparison * * @param {string} mid of Business Unit * @param {string} type of metadata * @param {string} action of SOAP request * @param {string} ext file extension * @returns {Promise.<string>} file in string form */ export function getExpectedFile(mid, type, action, ext) { const path = `/resources/${mid}/${type}/${action}-expected.${ext}`; console.log(loadingFile + path); // eslint-disable-line no-console return File.readFile(`./test` + path, 'utf8'); } /** * setup mocks for API and FS * * @param {boolean} [isDeploy] if true, will mock deploy folder * @returns {void} */ export function mockSetup(isDeploy) { cache.clearCache(); // no need to execute this again if we ran it a 2nd time for deploy - already done in standard setup if (!isDeploy) { // reset all options to default const resetOptions = {}; // get known options and make sure none are set for (const option in handler.knownOptions) { resetOptions[option] = undefined; } // test config resetOptions.debug = true; resetOptions.noLogFile = true; handler.setOptions(resetOptions); } File.prettierConfig = null; apimock = new MockAdapter(axiosInstance, { onNoMatch: 'throwException' }); // set access_token to mid to allow for autorouting of mock to correct resources apimock.onPost(authResources.success.url).reply((config) => { authResources.success.response.access_token = JSON.parse(config.data).account_id; return [authResources.success.status, authResources.success.response]; }); apimock.onPost(soapUrl).reply((config) => handleSOAPRequest(config)); apimock .onAny(new RegExp(`^${escapeRegExp(restUrl)}`)) .reply((config) => handleRESTRequest(config)); const fsMockConf = { '.beautyamp.json': fsmock.load( path.resolve(__dirname, '../boilerplate/files/.beautyamp.json') ), '.prettierrc': fsmock.load(path.resolve(__dirname, '../boilerplate/files/.prettierrc')), 'eslint.config.js': fsmock.load( path.resolve(__dirname, '../boilerplate/files/eslint.config.js') ), '.mcdevrc.json': fsmock.load(path.resolve(__dirname, 'mockRoot/.mcdevrc.json')), '.mcdev-auth.json': fsmock.load(path.resolve(__dirname, 'mockRoot/.mcdev-auth.json')), '.mcdev-validations.js': fsmock.load( path.resolve(__dirname, 'mockRoot/.mcdev-validations.js') ), 'boilerplate/config.json': fsmock.load( path.resolve(__dirname, '../boilerplate/config.json') ), test: fsmock.load(path.resolve(__dirname)), // the following node_modules are required for prettier's SQL parser to work 'node_modules/prettier': fsmock.load(path.resolve(__dirname, '../node_modules/prettier')), 'node_modules/prettier-plugin-sql': fsmock.load( path.resolve(__dirname, '../node_modules/prettier-plugin-sql') ), 'node_modules/beauty-amp-core2': fsmock.load( path.resolve(__dirname, '../node_modules/beauty-amp-core2') ), 'node_modules/node-sql-parser': fsmock.load( path.resolve(__dirname, '../node_modules/node-sql-parser') ), 'node_modules/big-integer': fsmock.load( path.resolve(__dirname, '../node_modules/big-integer') ), 'node_modules/sql-formatter': fsmock.load( path.resolve(__dirname, '../node_modules/sql-formatter') ), 'node_modules/jsox': fsmock.load(path.resolve(__dirname, '../node_modules/jsox')), 'node_modules/nearley': fsmock.load(path.resolve(__dirname, '../node_modules/nearley')), }; if (isDeploy) { // load files we manually prepared for a direct test of `deploy` command fsMockConf.deploy = fsmock.load(path.resolve(__dirname, 'mockRoot/deploy')); } fsmock(fsMockConf); // ! reset exitCode or else tests could influence each other; do this in mockSetup to to ensure correct starting value process.exitCode = 0; } /** * resets mocks for API and FS * * @returns {void} */ export function mockReset() { // remove all options that might have been set by previous tests for (const key in Util.OPTIONS) { if (Object.prototype.hasOwnProperty.call(Util.OPTIONS, key)) { delete Util.OPTIONS[key]; } } // avoid spillover from other tests ReplaceContentBlockReference.resetCacheMap(); // reset sfmc login auth.clearSessions(); fsmock.restore(); apimock.restore(); } /** * helper to return amount of api callouts * * @param {boolean} [includeToken] if true, will include token calls in count * @returns {object} of API history */ export function getAPIHistoryLength(includeToken) { const historyArr = Object.values(apimock.history).flat(); if (includeToken) { return historyArr.length; } return historyArr.filter((item) => item.url !== '/v2/token').length; } /** * helper to return api history * * @returns {object} of API history */ export function getAPIHistory() { return apimock.history; } /** * * @param {'patch'|'delete'|'post'|'get'|'put'} method http method * @param {string} url url without domain, end on % if you want to search with startsWith() * @param {boolean} returnAll useful for post requests that often have multiple callouts with the same url * @returns {object} json payload of the request */ export function getRestCallout(method, url, returnAll = false) { if (!apimock.history[method]?.length) { console.log(`${tWarn} No history for method ${method}.`); // eslint-disable-line no-console const methods = Object.keys(apimock.history) .filter((el) => apimock.history[el]?.length) .join(', '); console.error(`Available methods: ${methods}`); // eslint-disable-line no-console return null; } const subset = apimock.history[method]; /** * helper for filter/find * * @param {any} item history item * @returns {boolean} if item matches */ function findCallout(item) { return url.endsWith('%') ? item.url.startsWith(url.slice(0, -1)) : item.url === url; } const myCallout = returnAll ? subset.filter(findCallout) : subset.find(findCallout); if (!myCallout) { console.error(`${tWarn} No callout found for ${method} ${url}`); // eslint-disable-line no-console const urls = [...new Set(subset.map((el) => el.url))].join('\n- '); const methods = Object.keys(apimock.history) .filter((el) => apimock.history[el]?.length) .join(', '); console.error(`Available methods: ${methods}`); // eslint-disable-line no-console console.error(`Available unique urls in method ${method}:\n- ${urls}`); // eslint-disable-line no-console return null; } return returnAll ? myCallout.map((el) => JSON.parse(el.data)) : JSON.parse(myCallout.data); } /** * * @param {'Schedule'|'Retrieve'|'Create'|'Update'|'Delete'|'Describe'|'Execute'} requestAction soap request types * @param {string} [objectType] optionall filter requests by object * @returns {object[]} json payload of the requests */ export function getSoapCallouts(requestAction, objectType) { const method = 'post'; const url = '/Service.asmx'; const subset = apimock.history[method]; const myCallout = subset // find soap requests .filter((item) => item.url === url) // find soap requestst of the correct request type .filter((item) => item.headers.SOAPAction === requestAction) // find soap requestst of the correct request type .filter((item) => !objectType || item.data.includes('<ObjectType') ? item.data.split('<ObjectType>')[1].split('</ObjectType>')[0] === objectType : item.data.includes('<Objects xsi:type="') ? item.data.split('<Objects xsi:type="')[1].split('">')[0] === objectType : false ) .map((item) => item.data); if (!myCallout) { console.error(`${tWarn} No callout found for ${requestAction} ${objectType || ''}`); // eslint-disable-line no-console return null; } return myCallout; } /** * helper to return most important fields for each api call * * @returns {object} of API history */ export function getAPIHistoryDebug() { const historyArr = Object.values(apimock.history) .flat() .map((item) => { const log = { method: item.method, url: item.url }; if (item.data) { log.body = item.data; } return log; }); return historyArr; } /** * helper to return most important fields for each api call * * @returns {void} of API history */ export function logAPIHistoryDebug() { console.log(getAPIHistoryDebug()); // eslint-disable-line no-console } /** * escapes string for regex * * @param {string} str to escape * @returns {string} escaped string */ function escapeRegExp(str) { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); // $& means the whole matched string }