Repository: Accenture/sfmc-devtools (js)

Issue:
[FEATURE] `execute` query command
The Return is very basic with just “OK” or “Error”.

POST /automation/v1/queries/{{queryID}}/actions/start/

should support 
- `*` for BUs
- marketlist for BUs
- multiple query keys

later, the same command can be expanded for starting journeys, triggeredSends, ...

Pull request description:
Feature/730 feature execute query command
# PR details

## What changes did you make? (Give an overview)

- closes #730 
Added a function to execute a query/queries. Execute all queries on all BUs if * is provided instead of a BU and no keys


## Checklist

- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] test scripts updated
- [x] Wiki updated (if applicable)


Patch:
diff --git a/docs/dist/documentation.md b/docs/dist/documentation.md
index d9363cd3..fdcd4771 100644
--- a/docs/dist/documentation.md
+++ b/docs/dist/documentation.md
@@ -496,6 +496,8 @@ main class
     * [.buildDefinition(businessUnit, selectedType, name, market)](#Mcdev.buildDefinition) ⇒ <code>Promise.&lt;void&gt;</code>
     * [.buildDefinitionBulk(listName, type, name)](#Mcdev.buildDefinitionBulk) ⇒ <code>Promise.&lt;void&gt;</code>
     * [.getFilesToCommit(businessUnit, selectedType, keyArr)](#Mcdev.getFilesToCommit) ⇒ <code>Promise.&lt;Array.&lt;string&gt;&gt;</code>
+    * [.execute(businessUnit, [selectedTypesArr], keys)](#Mcdev.execute) ⇒ <code>Promise.&lt;boolean&gt;</code>
+    * [._executeBU(cred, bu, [selectedTypesArr], keyArr)](#Mcdev._executeBU) ⇒ <code>Promise.&lt;boolean&gt;</code>
 
 <a name="Mcdev.setSkipInteraction"></a>
 
@@ -748,6 +750,35 @@ Build a specific metadata file based on a template using a list of bu-market com
 | selectedType | <code>string</code> | supported metadata type |
 | keyArr | <code>Array.&lt;string&gt;</code> | customerkey of the metadata |
 
+<a name="Mcdev.execute"></a>
+
+### Mcdev.execute(businessUnit, [selectedTypesArr], keys) ⇒ <code>Promise.&lt;boolean&gt;</code>
+Start an item (query)
+
+**Kind**: static method of [<code>Mcdev</code>](#Mcdev)  
+**Returns**: <code>Promise.&lt;boolean&gt;</code> - true if all started successfully, false if not  
+
+| Param | Type | Description |
+| --- | --- | --- |
+| businessUnit | <code>string</code> | name of BU |
+| [selectedTypesArr] | <code>Array.&lt;TYPE.SupportedMetadataTypes&gt;</code> | limit to given metadata types |
+| keys | <code>Array.&lt;string&gt;</code> | customerkey of the metadata |
+
+<a name="Mcdev._executeBU"></a>
+
+### Mcdev.\_executeBU(cred, bu, [selectedTypesArr], keyArr) ⇒ <code>Promise.&lt;boolean&gt;</code>
+helper for @link execute
+
+**Kind**: static method of [<code>Mcdev</code>](#Mcdev)  
+**Returns**: <code>Promise.&lt;boolean&gt;</code> - true if all items were executed, false otherwise  
+
+| Param | Type | Description |
+| --- | --- | --- |
+| cred | <code>string</code> | name of Credential |
+| bu | <code>string</code> | name of BU |
+| [selectedTypesArr] | <code>Array.&lt;TYPE.SupportedMetadataTypes&gt;</code> | limit execution to given metadata type |
+| keyArr | <code>Array.&lt;string&gt;</code> | customerkey of the metadata |
+
 <a name="Asset"></a>
 
 ## Asset ⇐ [<code>MetadataType</code>](#MetadataType)
@@ -3155,6 +3186,7 @@ Provides default functionality that can be overwritten by child metadata type cl
     * [.create(metadata, deployDir)](#MetadataType.create) ⇒ <code>void</code>
     * [.update(metadata, [metadataBefore])](#MetadataType.update) ⇒ <code>void</code>
     * [.refresh()](#MetadataType.refresh) ⇒ <code>void</code>
+    * [.execute()](#MetadataType.execute) ⇒ <code>void</code>
     * [.hasChanged(cachedVersion, metadata, [fieldName])](#MetadataType.hasChanged) ⇒ <code>boolean</code>
     * [.hasChangedGeneric(cachedVersion, metadata, [fieldName], [silent])](#MetadataType.hasChangedGeneric) ⇒ <code>boolean</code>
     * [.upsert(metadataMap, deployDir, [isRefresh])](#MetadataType.upsert) ⇒ <code>Promise.&lt;TYPE.MetadataTypeMap&gt;</code>
@@ -3167,6 +3199,7 @@ Provides default functionality that can be overwritten by child metadata type cl
     * [.getSOAPErrorMsg(ex)](#MetadataType.getSOAPErrorMsg) ⇒ <code>string</code>
     * [.retrieveSOAP(retrieveDir, [requestParams], [singleRetrieve], [additionalFields])](#MetadataType.retrieveSOAP) ⇒ <code>Promise.&lt;TYPE.MetadataTypeMapObj&gt;</code>
     * [.retrieveREST(retrieveDir, uri, [templateVariables], [singleRetrieve])](#MetadataType.retrieveREST) ⇒ <code>Promise.&lt;{metadata: (TYPE.MetadataTypeMap\|TYPE.MetadataTypeItem), type: string}&gt;</code>
+    * [.executeREST(uri, key)](#MetadataType.executeREST) ⇒ <code>Promise.&lt;string&gt;</code>
     * [.runDocumentOnRetrieve([singleRetrieve], metadataMap)](#MetadataType.runDocumentOnRetrieve) ⇒ <code>Promise.&lt;void&gt;</code>
     * [.parseResponseBody(body, [singleRetrieve])](#MetadataType.parseResponseBody) ⇒ <code>TYPE.MetadataTypeMap</code>
     * [.deleteFieldByDefinition(metadataEntry, fieldPath, definitionProperty, origin)](#MetadataType.deleteFieldByDefinition) ⇒ <code>void</code>
@@ -3466,6 +3499,12 @@ Abstract update method that needs to be implemented in child metadata type
 ### MetadataType.refresh() ⇒ <code>void</code>
 Abstract refresh method that needs to be implemented in child metadata type
 
+**Kind**: static method of [<code>MetadataType</code>](#MetadataType)  
+<a name="MetadataType.execute"></a>
+
+### MetadataType.execute() ⇒ <code>void</code>
+Abstract execute method that needs to be implemented in child metadata type
+
 **Kind**: static method of [<code>MetadataType</code>](#MetadataType)  
 <a name="MetadataType.hasChanged"></a>
 
@@ -3633,6 +3672,19 @@ Retrieves Metadata for Rest Types
 | [templateVariables] | <code>TYPE.TemplateMap</code> | variables to be replaced in the metadata |
 | [singleRetrieve] | <code>string</code> \| <code>number</code> | key of single item to filter by |
 
+<a name="MetadataType.executeREST"></a>
+
+### MetadataType.executeREST(uri, key) ⇒ <code>Promise.&lt;string&gt;</code>
+Used to execute a query/automation etc.
+
+**Kind**: static method of [<code>MetadataType</code>](#MetadataType)  
+**Returns**: <code>Promise.&lt;string&gt;</code> - 'OK' if started execution successfully, otherwise - 'Error'  
+
+| Param | Type | Description |
+| --- | --- | --- |
+| uri | <code>string</code> | REST endpoint where the POST request should be sent |
+| key | <code>string</code> | item key |
+
 <a name="MetadataType.runDocumentOnRetrieve"></a>
 
 ### MetadataType.runDocumentOnRetrieve([singleRetrieve], metadataMap) ⇒ <code>Promise.&lt;void&gt;</code>
@@ -4548,6 +4600,7 @@ Query MetadataType
 
 * [Query](#Query) ⇐ [<code>MetadataType</code>](#MetadataType)
     * [.retrieve(retrieveDir, [_], [__], [key])](#Query.retrieve) ⇒ <code>Promise.&lt;{metadata: TYPE.QueryMap, type: string}&gt;</code>
+    * [.execute(keyArr)](#Query.execute) ⇒ <code>Promise.&lt;boolean&gt;</code>
     * [.retrieveForCache()](#Query.retrieveForCache) ⇒ <code>Promise.&lt;{metadata: TYPE.QueryMap, type: string}&gt;</code>
     * [.retrieveAsTemplate(templateDir, name, templateVariables)](#Query.retrieveAsTemplate) ⇒ <code>Promise.&lt;{metadata: Query, type: string}&gt;</code>
     * [.postRetrieveTasks(metadata)](#Query.postRetrieveTasks) ⇒ <code>TYPE.CodeExtractItem</code>
@@ -4577,6 +4630,18 @@ Retrieves Metadata of queries
 | [__] | <code>void</code> | unused parameter |
 | [key] | <code>string</code> | customer key of single item to retrieve |
 
+<a name="Query.execute"></a>
+
+### Query.execute(keyArr) ⇒ <code>Promise.&lt;boolean&gt;</code>
+a function to start query execution via API
+
+**Kind**: static method of [<code>Query</code>](#Query)  
+**Returns**: <code>Promise.&lt;boolean&gt;</code> - Returns true if all items were executed successfully, otherwise false  
+
+| Param | Type | Description |
+| --- | --- | --- |
+| keyArr | <code>Array.&lt;string&gt;</code> | customerkey of the metadata |
+
 <a name="Query.retrieveForCache"></a>
 
 ### Query.retrieveForCache() ⇒ <code>Promise.&lt;{metadata: TYPE.QueryMap, type: string}&gt;</code>
diff --git a/lib/cli.js b/lib/cli.js
index 992d6f9f..f96bf006 100644
--- a/lib/cli.js
+++ b/lib/cli.js
@@ -389,6 +389,30 @@ yargs
             Mcdev.refresh(argv.BU, argv.TYPE, csvToArray(argv.KEY));
         },
     })
+    .command({
+        command: 'execute <BU> <TYPE> <KEY>',
+        aliases: ['exec'],
+        desc: 'executes the entity (query/journey/automation etc.)',
+        builder: (yargs) => {
+            yargs
+                .positional('BU', {
+                    type: 'string',
+                    describe: 'the business unit where to start an item',
+                })
+                .positional('TYPE', {
+                    type: 'string',
+                    describe: 'metadata type',
+                })
+                .positional('KEY', {
+                    type: 'string',
+                    describe: 'key(s) of the metadata component(s)',
+                });
+        },
+        handler: (argv) => {
+            Mcdev.setOptions(argv);
+            Mcdev.execute(argv.BU, csvToArray(argv.TYPE), csvToArray(argv.KEY));
+        },
+    })
     .command({
         command: 'upgrade',
         aliases: ['up'],
diff --git a/lib/index.js b/lib/index.js
index ecd28d29..5f427a14 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -682,6 +682,158 @@ class Mcdev {
             return DevOps.getFilesToCommit(properties, buObject, selectedType, keyArr);
         }
     }
+    /**
+     * Start an item (query)
+     *
+     * @param {string} businessUnit name of BU
+     * @param {TYPE.SupportedMetadataTypes[]} [selectedTypesArr] limit to given metadata types
+     * @param {string[]} keys customerkey of the metadata
+     * @returns {Promise.<boolean>} true if all started successfully, false if not
+     */
+    static async execute(businessUnit, selectedTypesArr, keys) {
+        Util.logger.info('mcdev:: Execute');
+        const properties = await config.getProperties();
+        let counter_credBu = 0;
+        let counter_failed = 0;
+        if (!(await config.checkProperties(properties))) {
+            // return null here to avoid seeing 2 error messages for the same issue
+            return null;
+        }
+        if (Array.isArray(selectedTypesArr)) {
+            // types and keys can be provided but for each type all provided keys are applied as filter
+            for (const selectedType of Array.isArray(selectedTypesArr)
+                ? selectedTypesArr
+                : Object.keys(selectedTypesArr)) {
+                if (!Util._isValidType(selectedType)) {
+                    return;
+                }
+            }
+        }
+        if (businessUnit === '*') {
+            Util.logger.info(
+                '\n               :: Executing the entity on all BUs for all credentials'
+            );
+            let counter_credTotal = 0;
+            for (const cred in properties.credentials) {
+                Util.logger.info(`\n               :: Executing the entity on all BUs for ${cred}`);
+
+                for (const bu in properties.credentials[cred].businessUnits) {
+                    if (await this._executeBU(cred, bu, selectedTypesArr, keys)) {
+                        counter_credBu++;
+                    } else {
+                        counter_failed++;
+                    }
+                    Util.restartLogger();
+                }
+                counter_credTotal += counter_credBu;
+                Util.logger.info(
+                    `\n               :: Executed the entity on ${counter_credBu} BUs for ${cred}\n`
+                );
+            }
+            Util.logger.info(
+                `\n               :: Executed the entity on ${counter_credTotal} BUs in total\n`
+            );
+        } else {
+            let [cred, bu] = businessUnit ? businessUnit.split('/') : [null, null];
+            // to allow all-BU via user selection we need to run this here already
+            if (
+                properties.credentials &&
+                (!properties.credentials[cred] ||
+                    (bu !== '*' && !properties.credentials[cred].businessUnits[bu]))
+            ) {
+                const buObject = await Cli.getCredentialObject(
+                    properties,
+                    cred === null ? null : cred + '/' + bu,
+                    null,
+                    true
+                );
+                if (buObject === null) {
+                    return;
+                } else {
+                    cred = buObject.credential;
+                    bu = buObject.businessUnit;
+                }
+            }
+            if (bu === '*' && properties.credentials && properties.credentials[cred]) {
+                Util.logger.info(`\n               :: Executing the entity on all BUs for ${cred}`);
+                let counter_credBu = 0;
+                for (const bu in properties.credentials[cred].businessUnits) {
+                    if (await this._executeBU(cred, bu, selectedTypesArr, keys)) {
+                        counter_credBu++;
+                    } else {
+                        counter_failed++;
+                    }
+                    Util.restartLogger();
+                }
+                Util.logger.info(
+                    `\n               :: Executed the entity on ${counter_credBu} BUs for ${cred}\n`
+                );
+            } else {
+                // execute the entity on one BU only
+                if (await this._executeBU(cred, bu, selectedTypesArr, keys)) {
+                    counter_credBu++;
+                } else {
+                    counter_failed++;
+                }
+                Util.logger.info(`\n               :: Done\n`);
+            }
+        }
+        if (counter_credBu !== 0) {
+            Util.logger.info(`\n               :: Executed query on ${counter_credBu} BUs\n`);
+        }
+        return counter_failed === 0 ? true : false;
+    }
+    /**
+     * helper for @link execute
+     *
+     * @param {string} cred name of Credential
+     * @param {string} bu name of BU
+     * @param {TYPE.SupportedMetadataTypes[]} [selectedTypesArr] limit execution to given metadata type
+     * @param {string[]} keyArr customerkey of the metadata
+     * @returns {Promise.<boolean>} true if all items were executed, false otherwise
+     */
+    static async _executeBU(cred, bu, selectedTypesArr, keyArr) {
+        const properties = await config.getProperties();
+        let counter_failed = 0;
+        const buObject = await Cli.getCredentialObject(
+            properties,
+            cred === null ? null : cred + '/' + bu,
+            null,
+            true
+        );
+        if (!keyArr || (Array.isArray(keyArr) && !keyArr.length)) {
+            throw new Error('No keys were provided');
+        }
+        if (!selectedTypesArr || (Array.isArray(selectedTypesArr) && !selectedTypesArr.length)) {
+            throw new Error('No type was provided');
+        }
+        if (buObject !== null) {
+            cache.initCache(buObject);
+            cred = buObject.credential;
+            bu = buObject.businessUnit;
+        }
+        Util.logger.info(
+            `\n               :: Executing ${selectedTypesArr.join(', ')} on ${cred}/${bu}\n`
+        );
+        try {
+            // more than one type was provided, iterate types and execute items
+            for (const type of selectedTypesArr) {
+                try {
+                    MetadataTypeInfo[type].client = auth.getSDK(buObject);
+                } catch (ex) {
+                    Util.logger.error(ex.message);
+                    return;
+                }
+                // result will be undefined (false) if execute is not supported for the type
+                if (!(await MetadataTypeInfo[type].execute(keyArr))) {
+                    counter_failed++;
+                }
+            }
+        } catch (ex) {
+            Util.logger.errorStack(ex, 'mcdev.execute failed');
+        }
+        return counter_failed === 0 ? true : false;
+    }
 }
 
 module.exports = Mcdev;
diff --git a/lib/metadataTypes/MetadataType.js b/lib/metadataTypes/MetadataType.js
index 69b58259..e8402c4f 100644
--- a/lib/metadataTypes/MetadataType.js
+++ b/lib/metadataTypes/MetadataType.js
@@ -460,6 +460,18 @@ class MetadataType {
         return;
     }
 
+    /**
+     * Abstract execute method that needs to be implemented in child metadata type
+     *
+     * @returns {void}
+     */
+    static execute() {
+        Util.logger.error(
+            ` ☇ skipping ${this.definition.type}: execute is not supported yet for ${this.definition.type}`
+        );
+        return;
+    }
+
     /**
      * test if metadata was actually changed or not to potentially skip it during deployment
      *
@@ -1066,6 +1078,26 @@ class MetadataType {
             type: this.definition.type,
         };
     }
+    /**
+     * Used to execute a query/automation etc.
+     *
+     * @param {string} uri REST endpoint where the POST request should be sent
+     * @param {string} key item key
+     * @returns {Promise.<string>} 'OK' if started execution successfully, otherwise - 'Error'
+     */
+    static async executeREST(uri, key) {
+        try {
+            const response = await this.client.rest.post(uri, {}); // payload is empty for this request
+            if (response === 'OK') {
+                Util.logger.info(`Executed ${this.definition.type}: ${key}`);
+            } else {
+                throw new Error(response);
+            }
+            return response;
+        } catch (ex) {
+            Util.logger.error(`Failed to execute ${this.definition.type} ${key}: ${ex.message}`);
+        }
+    }
 
     /**
      * helper for {@link retrieveREST} and {@link retrieveSOAP}
diff --git a/lib/metadataTypes/Query.js b/lib/metadataTypes/Query.js
index 1b3d331d..e9149613 100644
--- a/lib/metadataTypes/Query.js
+++ b/lib/metadataTypes/Query.js
@@ -50,6 +50,32 @@ class Query extends MetadataType {
             key
         );
     }
+    /**
+     * a function to start query execution via API
+     *
+     * @param {string[]} keyArr customerkey of the metadata
+     * @returns {Promise.<boolean>} Returns true if all items were executed successfully, otherwise false
+     */
+    static async execute(keyArr) {
+        const results = [];
+        // works only with objectId
+        let objectId;
+        for (const key of keyArr) {
+            if (key) {
+                objectId = await this._getObjectIdForSingleRetrieve(key);
+                if (!objectId) {
+                    Util.logger.info(`Skipping ${key} - did not find an item with such key`);
+                    break;
+                }
+            }
+            results.push(
+                super.executeREST(`/automation/v1/queries/${objectId}/actions/start/`, key)
+            );
+        }
+        const successCounter = (await Promise.all(results)).filter((r) => r === 'OK').length;
+        Util.logger.info(`Executed ${successCounter} of ${keyArr.length} items`);
+        return successCounter === keyArr.length;
+    }
     /**
      * helper to allow us to select single metadata entries via REST
      *


Test patch:
diff --git a/test/resourceFactory.js b/test/resourceFactory.js
index 13b9a664..42419419 100644
--- a/test/resourceFactory.js
+++ b/test/resourceFactory.js
@@ -121,15 +121,15 @@ exports.handleRESTRequest = async (config) => {
                 'resources',
                 config.headers.Authorization.replace('Bearer ', ''),
                 urlObj.pathname,
-                config.method + '-response.json'
+                config.method + '-response'
             )
             .replace(':', '_'); // replace : with _ for Windows
 
-        if (await fs.pathExists(testPath)) {
+        if (await fs.pathExists(testPath + '.json')) {
             // build filter logic to ensure templating works
             if (filterName) {
                 const response = JSON.parse(
-                    await fs.readFile(testPath, {
+                    await fs.readFile(testPath + '.json', {
                         encoding: 'utf8',
                     })
                 );
@@ -139,11 +139,18 @@ exports.handleRESTRequest = async (config) => {
             } else {
                 return [
                     200,
-                    await fs.readFile(testPath, {
+                    await fs.readFile(testPath + '.json', {
                         encoding: 'utf8',
                     }),
                 ];
             }
+        } else if (await fs.pathExists(testPath + '.txt')) {
+            return [
+                200,
+                await fs.readFile(testPath + '.txt', {
+                    encoding: 'utf8',
+                }),
+            ];
         } else {
             /* eslint-disable no-console */
             console.log(
diff --git a/test/resources/9999999/automation/v1/queries/549f0568-607c-4940-afef-437965094dat/actions/start/post-response.txt b/test/resources/9999999/automation/v1/queries/549f0568-607c-4940-afef-437965094dat/actions/start/post-response.txt
new file mode 100644
index 00000000..a0aba931
--- /dev/null
+++ b/test/resources/9999999/automation/v1/queries/549f0568-607c-4940-afef-437965094dat/actions/start/post-response.txt
@@ -0,0 +1,1 @@
+OK
\ No newline at end of file
diff --git a/test/type.query.test.js b/test/type.query.test.js
index b64890ff..9d93f862 100644
--- a/test/type.query.test.js
+++ b/test/type.query.test.js
@@ -277,4 +277,16 @@ describe('type: query', () => {
             return;
         });
     });
+    describe('Execute ================', () => {
+        it('Should start executing a query', async () => {
+            const execute = await handler.execute(
+                'testInstance/testBU',
+                ['query'],
+                ['testExistingQuery']
+            );
+            assert.equal(process.exitCode, false, 'execute should not have thrown an error');
+            assert.equal(execute, true, 'query was supposed to be executed');
+            return;
+        });
+    });
 });