Repository: slackapi/slack-github-action (js)

Issue:
please expose `slackApiUrl`
as mentioned here https://tools.slack.dev/node-slack-sdk/web-api/#custom-api-url
one can customize slack API URL like `options.slackApiUrl = 'http://localhost:8888/api/';`
this is not exposed in the action yet

addition in this area https://github.com/slackapi/slack-github-action/blob/main/src/client.js#L27

please do

Pull request description:
feat: include an 'api' option to customize the slack api method url
### Summary

This PR introduces an `api` option to the inputs of this action as a "custom API URL to send Slack API method requests to".

Resolves #389.

### Preview

```diff
  - name: Send to a custom API URL
    uses: slackapi/slack-github-action@v2.0.0
    with:
+     api: http://localhost:8080
      method: chat.postMessage
      token: ${{ secrets.SLACK_BOT_TOKEN }}
      payload: |
        channel: ${{ secrets.SLACK_CHANNEL_ID }}
        text: "What's happening on localhost?"
```

### Notes

Changes in this PR also include refactors of #408 needed for testing 🙏 ✨

Super open to naming suggestions since "api" might not be so clear right away, but IMO it matches "method" well 🤔 

### Requirements

* [x] I've read and understood the [Contributing Guidelines](https://github.com/slackapi/slack-github-action/blob/main/.github/contributing.md) and have done my best effort to follow them.
* [x] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct).

Patch:
diff --git a/README.md b/README.md
index 6dfeae7..d1b22c1 100644
--- a/README.md
+++ b/README.md
@@ -452,6 +452,26 @@ Behind the scenes, [automatic retries][retries] are handled with the
 [`@slack/web-api`][slack-web-api] package for Slack API methods, and
 [`axios-retry`][axios-retry] when sending with a webhook.
 
+### Sending to a custom API URL
+
+In certain circumstances, such as testing the sent payload, a
+[custom API URL][custom-api-url] can be used to change where `method` requests
+are sent:
+
+```yaml
+- name: Send to a custom API URL
+  uses: slackapi/slack-github-action@v2.0.0
+  with:
+    api: http://localhost:8080
+    method: chat.postMessage
+    token: ${{ secrets.SLACK_BOT_TOKEN }}
+    payload: |
+      channel: ${{ secrets.SLACK_CHANNEL_ID }}
+      text: "What's happening on localhost?"
+```
+
+The default value of `api` is "https://slack.com/api/" for steps using `method`.
+
 ## Expected outputs
 
 Each technique above [outputs values][github-outputs] that can be used as inputs
@@ -517,6 +537,7 @@ All contributions are encouraged! Check out the
 [config-tokens]: https://api.slack.com/reference/manifests#config-tokens
 [contributing]: .github/contributing.md
 [conversation]: https://api.slack.com/types/conversation
+[custom-api-url]: https://tools.slack.dev/node-slack-sdk/web-api/#custom-api-url
 [epoch]: https://en.wikipedia.org/wiki/Unix_time
 [event-context]: https://github.com/actions/toolkit/blob/main/packages/github/src/context.ts#L6
 [event-payload]: https://docs.github.com/en/webhooks/webhook-events-and-payloads
diff --git a/action.yml b/action.yml
index db07c14..d5ed8ff 100644
--- a/action.yml
+++ b/action.yml
@@ -2,6 +2,9 @@ name: "Slack: Send to Slack"
 author: "slackapi"
 description: "Send data to Slack to start a Slack workflow in Workflow Builder, call a Slack API method, or post a message into a channel"
 inputs:
+  api:
+    description: "A custom API URL to send Slack API method requests to."
+    required: false
   errors:
     default: "false"
     description: "If the step exits with an error on errors or continues."
diff --git a/src/client.js b/src/client.js
index 8d371b2..e476a88 100644
--- a/src/client.js
+++ b/src/client.js
@@ -26,6 +26,7 @@ export default class Client {
       agent: this.proxies(config)?.httpsAgent,
       logger: config.logger,
       retryConfig: this.retries(config.inputs.retries),
+      slackApiUrl: config.inputs.api || undefined,
     });
     try {
       /**
diff --git a/src/config.js b/src/config.js
index 9a3ff11..8ba4502 100644
--- a/src/config.js
+++ b/src/config.js
@@ -40,6 +40,7 @@ export default class Config {
 
   /**
    * @typedef Inputs - Values provided to this job.
+   * @property {string?} api - A custom API URL to send method requests to.
    * @property {boolean} errors - If the job should exit after errors or succeed.
    * @property {string?} method - The Slack API method to call.
    * @property {string?} payload - Request contents from the provided input.
@@ -101,6 +102,7 @@ export default class Config {
     this.logger = new Logger(core).logger;
     this.webapi = webapi;
     this.inputs = {
+      api: core.getInput("api"),
       errors: core.getBooleanInput("errors"),
       method: core.getInput("method"),
       payload: core.getInput("payload"),


Test patch:
diff --git a/test/client.spec.js b/test/client.spec.js
index 5bfff66..58f2dcd 100644
--- a/test/client.spec.js
+++ b/test/client.spec.js
@@ -73,6 +73,7 @@ describe("client", () => {
         core: core,
         logger: new Logger(core).logger,
         inputs: {
+          api: "http://localhost:8080/api",
           method: "pins.add",
           retries: "10",
           token: "xoxb-example-002",
@@ -86,6 +87,7 @@ describe("client", () => {
           agent: undefined,
           logger: config.logger,
           retryConfig: webapi.retryPolicies.tenRetriesInAboutThirtyMinutes,
+          slackApiUrl: "http://localhost:8080/api",
         }),
       );
     });
@@ -110,10 +112,10 @@ describe("client", () => {
         mocks.core.getInput.withArgs("method").returns("chat.postMessage");
         mocks.core.getInput.withArgs("token").returns("xoxb-example");
         mocks.core.getInput.withArgs("payload").returns(JSON.stringify(args));
-        mocks.api.resolves(response);
+        mocks.calls.resolves(response);
         await send(mocks.core);
-        assert.deepEqual(mocks.api.getCall(0).firstArg, "chat.postMessage");
-        assert.deepEqual(mocks.api.getCall(0).lastArg, args);
+        assert.deepEqual(mocks.calls.getCall(0).firstArg, "chat.postMessage");
+        assert.deepEqual(mocks.calls.getCall(0).lastArg, args);
         assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
         assert.equal(mocks.core.setOutput.getCall(0).lastArg, true);
         assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
@@ -158,10 +160,10 @@ describe("client", () => {
         mocks.core.getInput.withArgs("method").returns("chat.postMessage");
         mocks.core.getInput.withArgs("token").returns("xoxb-example");
         mocks.core.getInput.withArgs("payload").returns(JSON.stringify(args));
-        mocks.api.resolves(response);
+        mocks.calls.resolves(response);
         await send(mocks.core);
-        assert.deepEqual(mocks.api.getCall(0).firstArg, "chat.postMessage");
-        assert.deepEqual(mocks.api.getCall(0).lastArg, args);
+        assert.deepEqual(mocks.calls.getCall(0).firstArg, "chat.postMessage");
+        assert.deepEqual(mocks.calls.getCall(0).lastArg, args);
         assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
         assert.equal(mocks.core.setOutput.getCall(0).lastArg, true);
         assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
@@ -194,9 +196,9 @@ describe("client", () => {
         mocks.core.getInput.withArgs("method").returns("files.uploadV2");
         mocks.core.getInput.withArgs("token").returns("xoxp-example");
         mocks.core.getInput.withArgs("payload").returns(JSON.stringify(args));
-        mocks.api.resolves(response);
+        mocks.calls.resolves(response);
         await send(mocks.core);
-        assert.deepEqual(mocks.api.getCall(0).lastArg, args);
+        assert.deepEqual(mocks.calls.getCall(0).lastArg, args);
         assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
         assert.equal(mocks.core.setOutput.getCall(0).lastArg, true);
         assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
@@ -231,7 +233,7 @@ describe("client", () => {
         mocks.core.getInput.withArgs("method").returns("chat.postMessage");
         mocks.core.getInput.withArgs("token").returns("xoxb-example");
         mocks.core.getInput.withArgs("payload").returns(`"text": "hello"`);
-        mocks.api.rejects(errors.requestErrorWithOriginal(response, true));
+        mocks.calls.rejects(errors.requestErrorWithOriginal(response, true));
         await send(mocks.core);
         assert.fail("Expected an error but none was found");
       } catch (error) {
@@ -266,7 +268,7 @@ describe("client", () => {
         mocks.core.getInput.withArgs("method").returns("chat.postMessage");
         mocks.core.getInput.withArgs("token").returns("xoxb-example");
         mocks.core.getInput.withArgs("payload").returns(`"text": "hello"`);
-        mocks.api.rejects(errors.httpErrorFromResponse(response));
+        mocks.calls.rejects(errors.httpErrorFromResponse(response));
         await send(mocks.core);
         assert.fail("Expected an error but none was found");
       } catch (error) {
@@ -302,7 +304,7 @@ describe("client", () => {
         mocks.core.getInput.withArgs("method").returns("chat.postMessage");
         mocks.core.getInput.withArgs("token").returns("xoxb-example");
         mocks.core.getInput.withArgs("payload").returns(`"text": "hello"`);
-        mocks.api.rejects(errors.platformErrorFromResult(response));
+        mocks.calls.rejects(errors.platformErrorFromResult(response));
         await send(mocks.core);
         assert.fail("Expected an error but none was found");
       } catch (error) {
@@ -331,7 +333,7 @@ describe("client", () => {
         mocks.core.getInput.withArgs("method").returns("chat.postMessage");
         mocks.core.getInput.withArgs("token").returns("xoxb-example");
         mocks.core.getInput.withArgs("payload").returns(`"text": "hello"`);
-        mocks.api.rejects(errors.platformErrorFromResult(response));
+        mocks.calls.rejects(errors.platformErrorFromResult(response));
         await send(mocks.core);
         assert.fail("Expected an error but none was found");
       } catch (error) {
@@ -357,7 +359,7 @@ describe("client", () => {
         mocks.core.getInput.withArgs("method").returns("chat.postMessage");
         mocks.core.getInput.withArgs("token").returns("xoxb-example");
         mocks.core.getInput.withArgs("payload").returns(`"text": "hello"`);
-        mocks.api.rejects(errors.rateLimitedErrorWithDelay(12));
+        mocks.calls.rejects(errors.rateLimitedErrorWithDelay(12));
         await send(mocks.core);
         assert.fail("Expected an error but none was found");
       } catch (error) {
diff --git a/test/config.spec.js b/test/config.spec.js
index 17e914f..470b35e 100644
--- a/test/config.spec.js
+++ b/test/config.spec.js
@@ -19,6 +19,7 @@ describe("config", () => {
 
   describe("inputs", () => {
     it("valid values are collected from the action inputs", async () => {
+      mocks.core.getInput.withArgs("api").returns("http://localhost:8080");
       mocks.core.getBooleanInput.withArgs("errors").returns(true);
       mocks.core.getInput.withArgs("method").returns("chat.postMessage");
       mocks.core.getInput.withArgs("payload").returns('"hello": "world"');
@@ -26,6 +27,7 @@ describe("config", () => {
       mocks.core.getInput.withArgs("retries").returns("0");
       mocks.core.getInput.withArgs("token").returns("xoxb-example");
       const config = new Config(mocks.core);
+      assert.equal(config.inputs.api, "http://localhost:8080");
       assert.equal(config.inputs.errors, true);
       assert.equal(config.inputs.method, "chat.postMessage");
       assert.equal(config.inputs.payload, '"hello": "world"');
diff --git a/test/index.spec.js b/test/index.spec.js
index 009a2f2..186db60 100644
--- a/test/index.spec.js
+++ b/test/index.spec.js
@@ -43,8 +43,8 @@ export class Mock {
    */
   constructor() {
     this.sandbox = sinon.createSandbox();
-    this.api = sinon.stub(webapi.WebClient.prototype, "apiCall");
     this.axios = this.sandbox.stub(axios);
+    this.calls = sinon.stub(webapi.WebClient.prototype, "apiCall");
     this.core = this.sandbox.stub(core);
     this.fs = this.sandbox.stub(fs);
     this.webapi = {
@@ -63,8 +63,8 @@ export class Mock {
    */
   reset() {
     this.sandbox.reset();
-    this.api.resetHistory();
     this.axios.post.resetHistory();
+    this.calls.resetHistory();
     this.core.getInput.reset();
     this.core.getInput.withArgs("errors").returns("false");
     this.core.getInput.withArgs("retries").returns("5");
diff --git a/test/send.spec.js b/test/send.spec.js
index b35398b..0d20056 100644
--- a/test/send.spec.js
+++ b/test/send.spec.js
@@ -40,7 +40,7 @@ describe("send", () => {
 
     it("token", async () => {
       process.env.SLACK_WEBHOOK_URL = "https://example.com"; // https://github.com/slackapi/slack-github-action/issues/373
-      mocks.api.resolves({ ok: true });
+      mocks.calls.resolves({ ok: true });
       mocks.core.getInput.withArgs("method").returns("chat.postMessage");
       mocks.core.getInput.withArgs("token").returns("xoxb-example");
       mocks.core.getInput.withArgs("payload").returns('"text": "hello"');