Trouble mocking freshchat-api in FDK unit test with serverless app

I’m hoping to use the @freshworks-jaya/freshchat-api Freshchat API to pull the conversation messages in a serverless app once the conversation is complete. I got the code working just fine when I published it.

My struggle though has been writing a unit test and mocking the Freshchat API since it has to be instantiated as a new class instance in server.js. I’ve tried using Sinon to mock Freshchat, but it still ends up calling the real API and fails to connect since I’m mocking the API domain and key.

Does anyone have any suggestions on how to properly mock the Freshchat API in the test?

Here is my test (written in TS) using Sinon
server.ts

import {Iparams, ProductEventPayloadVanilla} from '../../src/server/interfaces/EventPayload';
import {
    Agent,
    Channel,
    Group,
    LabelCategory,
    LabelSubcategory,
    User,
    Actor,
    ConversationStatus,
    ModelProperties,
    ProductEventData,
    ChangedStatus,
    ResponseDueType,
    Event
} from '@freshworks-jaya/marketplace-models';
import Freshchat from '@freshworks-jaya/freshchat-api';

const sinon = require('sinon');

describe('onConversationUpdateCallback', () => {

    const conversationId = '3345234qabaca';
    const api_domain = 'https://test.freshchat.com/v2';
    const api_key = 'TEST API TOKEN';
    const domain = 'http://google.com';

    const iparams: Iparams = {
        api_key: api_key,
        api_domain: api_domain,
        freshchat_domain: domain
    };

    let stub:any;

    beforeEach(() => {
        stub = sinon.stub(Freshchat.prototype, 'getConversationTranscript').returns("foo bar");
    });

    afterEach(() => {
        stub.restore();
    });

    it('should call getConversationTranscript when conversation status is resolved', async function () {
        const convoStatus = 'resolved';
        const data: ProductEventData = getProductEventData(convoStatus, '2021-10-11 10:00:00', '2021-10-12 10:00:00', conversationId);
        const payload: ProductEventPayloadVanilla = {
            iparams: iparams,
            data: data,
            account_id: 'foo',
            domain: 'foo',
            event: <Event>{},
            region: 'foo',
            timestamp: 0,
            version: 'foo',
        };

        await this.invoke('onConversationUpdate', payload);
        sinon.assert.calledOnce(stub);
    });


    it('should NOT call getConversationTranscript when conversation status is new', async function () {
        const convoStatus = 'new';
        const data: ProductEventData = getProductEventData(convoStatus, '2021-10-11 10:00:00', '2021-10-12 10:00:00', conversationId);
        const payload: ProductEventPayloadVanilla = {
            iparams: iparams,
            data: data,
            account_id: 'foo',
            domain: 'foo',
            event: <Event>{},
            region: 'foo',
            timestamp: 0,
            version: 'foo',
        };

        await this.invoke('onConversationUpdate', payload);
        sinon.assert.notCalled(stub);
    });
});

function getProductEventData(
    status: string,
    reopened_time: string,
    created_time: string,
    conversationId: string
) {
    return <ProductEventData> {
        actor: <Actor>{},
        associations: {
            agent: <Agent>{},
            channel: <Channel>{},
            group: <Group>{},
            label_category: <LabelCategory>{},
            label_subcategory: <LabelSubcategory>{},
            user: <User>{},
        },
        changes: {
            model_changes: {
                assigned_agent_id: ['', ''],
                assigned_group_id: ['', ''],
                label_category_id: ['', ''],
                label_subcategory_id: ['', ''],
                status: [<ChangedStatus>{}, <ChangedStatus>{}]
            }
        },
        conversation: {
            app_id: 'foo',
            assigned_agent_id: '',
            assigned_group_id: '',
            assigned_org_agent_id: '',
            assigned_org_group_id: '',
            assigned_time: '',
            channel_id:'',
            first_agent_assigned_time: '',
            first_group_assigned_time: '',
            group_assigned_time: '',
            is_offline: false,
            label_category_id: '',
            label_subcategory_id: '',
            resolved_time: '',
            response_due_type: <ResponseDueType>{},
            source: '',
            statistics: {
                agent_reassignment_time_bhrs: 0,
                agent_reassignment_time_chrs: 0,
                first_agent_assignment_time_bhrs: 0,
                first_agent_assignment_time_chrs: 0,
                first_group_assignment_time_bhrs: 0,
                first_group_assignment_time_chrs: 0,
                first_response_time_bhrs: 0,
                first_response_time_chrs: 0,
                group_reassignment_time_bhrs: 0,
                group_reassignment_time_chrs: 0,
                resolution_time_bhrs: 0,
                resolution_time_chrs: 0,
                wait_time_bhrs: 0,
                wait_time_chrs: 0,
            },
            user_id: 'foo',
            conversation_id: conversationId,
            status: <ConversationStatus>status,
            reopened_time: reopened_time,
            created_time: created_time
        },
        message: <ModelProperties>{},
    };
}

And here is my server.js file (written in TS)
server.ts

import {ProductEventPayloadVanilla} from './interfaces/EventPayload';
import Freshchat from '@freshworks-jaya/freshchat-api';

interface SalesforceMessage {
  user_email?: string;
  user_first_name?: string;
  user_last_name?: string;
  agent_email?: string;
  agent_first_name?: string;
  agent_last_name?: string;
  conversation_timestamp?: string;
  conversation_text?: string;
}

const appAlias = '<APP_ALIAS>';

/**
 * Get conversation message thread using Freshchat API
 *
 * @param payload - event payload
 *
 * @return message thread as text
 */
const getConversationThread = async (
  payload: ProductEventPayloadVanilla
): Promise<string> => {
  try {
    const freshchat = new Freshchat(
      payload.iparams.api_domain,
      payload.iparams.api_key
    );

    const appUrl = payload.iparams.freshchat_domain;
    const conversationId = payload.data.conversation.conversation_id;

    return freshchat.getConversationTranscript(
      appUrl,
      appAlias,
      conversationId,
      {
        output: 'text',
        isIncludeFreshchatLink: false,
        isFetchUntilLastResolve: true,
        timezoneOffset: 0,
        messagesLimit: 200,
      },
      {
        isExcludeNormal: false,
        isExcludePrivate: false,
        isExcludeSystem: true,
      }
    );
  } catch (error) {
    console.error(error);
    return Promise.reject(
      'Error in making get request to Freshchat to fetch conversation messages'
    );
  }
};

exports = {
  /**
   * onConversationUpdate callback method
   * (configured in manifest.json)
   *
   * @param payload - event payload
   */
  onConversationUpdateCallback: async function(
    payload: ProductEventPayloadVanilla
  ) {
    const conversationStatus = payload.data.conversation.status;
    if (conversationStatus !== 'resolved') {
      return;
    }

    try {
      const thread: string = await getConversationThread(payload);

      const resolvedTimestamp = payload.data.conversation.reopened_time;
      const createdTimestamp = payload.data.conversation.created_time;
      const conversationTimestamp = resolvedTimestamp
        ? resolvedTimestamp
        : createdTimestamp;

      const message: SalesforceMessage = {
        user_email: payload.data.associations.user.email,
        user_first_name: payload.data.associations.user.first_name,
        user_last_name: payload.data.associations.user.last_name,
        agent_email: payload.data.associations.agent.email,
        agent_first_name: payload.data.associations.agent.first_name,
        agent_last_name: payload.data.associations.agent.last_name,
        conversation_timestamp: conversationTimestamp,
        conversation_text: thread,
      };

      console.log(JSON.stringify(message));
    } catch (error) {
      console.log('Conversation not found');
    }
  },
};

You might want to try mocking the HTTP requests directly using something like nock.

@arunrajkumar235 I believe you are the right person to add any inputs here regarding @freshworks-jaya/freshchat-api.

Internally, freshchat-api only makes HTTP requests to the Freshchat public APIs to fetch conversation messages, agents and user.

So, like @kaustavdm mentioned, you could use something like nock to mock the HTTP requests.
Here’s the function defined in freshchat-api.

2 Likes

Thanks. I had considered using nock but I wanted to mock the Freshchat methods directly, not what was inside of them.

In case this helps anyone else, my eventual solution was to move all of my logic into a separate class to parse the payload. Then I was able to write unit tests for my new class with dependency injection.

I had a lot of difficulty with using fdk test, as no matter what I did, I could not properly mock anything. So I just ended up using mocha directly, and not having any tests for server.js, which turned into a simple controller.

server.ts

onConversationUpdateCallback: async function(
    payload: ProductEventPayloadVanilla
  ) {
    const freshchatClient = new Freshchat(
      payload.iparams.api_domain,
      payload.iparams.api_key
    );
    const messageBuilder = new MessageBuilder(freshchatClient);

    await messageBuilder.parsePayload(payload);
  },

Then my MessageBuilder.ts class looks like this:

export class MessageBuilder {
  /**
   * Initialize MessageBuilder
   *
   * @param freshchat_client client
   */
  constructor(
    protected readonly freshchat_client: Freshchat
  ) {}

  parsePayload = async (payload: ProductEventPayloadVanilla): Promise<void> => {
      return this.freshchat_client.getConversationTranscript(
      .......
 };

Then in my MessageBuilderTest.ts test I can mock the Freshchat API like this:

    import { MessageBuilder as Sut } from '../MessageBuilder';
    import Freshchat from '@freshworks-jaya/freshchat-api';

    it('call getConversationTranscript when resolved', async () => {
        let transcript_calls = 0;
        const freshchat_stub = <Freshchat>(<unknown>{
           getConversationTranscript: (_: any, __: any, ___: any, ____:any, _____:any) => {},
         });

        const sut = new Sut(
            freshchat_stub
        );

        freshchat_stub.getConversationTranscript = (
            _url: string,
            _account_id: string,
            _conversation_id: string,
            _config: any,
            _config2: any
        ) => {
            transcript_calls++;
            return Promise.resolve("Foo bar");
        };
     
      await sut.parsePayload(given_payload);
      expect(transcript_calls).to.equal(1);
 });
1 Like

Dependency injection, ftw! Thanks for sharing this.

The idea behind using HTTP request mocking for a HTTP API client lib, like this one, is to test whether the expected endpoints are called with the expected parameters, and whether your code gets the expected value returned. That way, even if you change the library in future, or write your own client code, your tests won’t break and will help ensure the replacement works as expected. In terms of separation of concerns for the system under test, you don’t want to be testing if a dependency has the methods as documented, rather if the end result matches expectations.

1 Like