diff --git a/.env.sample b/.env.sample index 7bee2a82..4d10d49a 100644 --- a/.env.sample +++ b/.env.sample @@ -8,13 +8,9 @@ API_KEY= API_SECRET= ADDRESS_SID= CONVERSATIONS_SERVICE_SID= -# ALLOWED_ORIGINS should be a comma-separated list of origins -ALLOWED_ORIGINS=http://localhost:3000 -## base endpoint of your local server. By default it is "http://localhost:3001" -REACT_APP_SERVER_URL=http://localhost:3001 -# Deployment key used to boot the webchat UI locally. +# Deployment key used to boot the Webchat UI locally. # You can also pass this as part of query params to REACT_APP_SERVER_URL REACT_APP_DEPLOYMENT_KEY= -# region for the host (i.e stage-us1, dev-us1, us1), defaults to us1(prod) +# Region for the host (i.e stage-us1, dev-us1, us1), defaults to us1(prod) REACT_APP_REGION='' diff --git a/.eslintrc b/.eslintrc index 3b8ece8a..9f76c077 100644 --- a/.eslintrc +++ b/.eslintrc @@ -39,5 +39,13 @@ "import/no-duplicates": ["error"], "spaced-comment": "warn", "prefer-named-capture-group": "off" - } + }, + "overrides": [ + { + "files": ["src/definitions.ts"], + "rules": { + "camelcase": ["off"] + } + } + ] } diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index b764cb42..f37635fd 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -39,13 +39,22 @@ describe("Index", () => { ); }); - it("sets endpoint correctly", () => { - const setEndpointSpy = jest.spyOn(sessionDataHandler, "setEndpoint"); + it("sets region correctly", () => { + const setRegionSpy = jest.spyOn(sessionDataHandler, "setRegion"); - const serverUrl = "serverUrl"; - initWebchat({ serverUrl, deploymentKey: "CV000000" }); + const region = "Foo"; + initWebchat({ deploymentKey: "CV000000", region }); - expect(setEndpointSpy).toBeCalledWith(serverUrl); + expect(setRegionSpy).toBeCalledWith(region); + }); + + it("sets deployment key correctly", () => { + const setDeploymentKeySpy = jest.spyOn(sessionDataHandler, "setDeploymentKey"); + + const deploymentKey = "Foo"; + initWebchat({ deploymentKey }); + + expect(setDeploymentKeySpy).toBeCalledWith(deploymentKey); }); it("initializes config", () => { @@ -59,27 +68,43 @@ describe("Index", () => { it("initializes config with provided config merged with default config", () => { const initConfigSpy = jest.spyOn(initActions, "initConfig"); - const serverUrl = "serverUrl"; - initWebchat({ serverUrl, deploymentKey: "CV000000" }); + const deploymentKey = "CV000000"; + initWebchat({ deploymentKey }); + + expect(initConfigSpy).toBeCalledWith(expect.objectContaining({ deploymentKey, theme: { isLight: true } })); + }); + + it("gives error when deploymentKey is missing", () => { + const logger = window.Twilio.getLogger("InitWebChat"); + const errorLoggerSpy = jest.spyOn(logger, "error"); + initWebchat(); + expect(errorLoggerSpy).toBeCalledTimes(1); + expect(errorLoggerSpy).toHaveBeenCalledWith("deploymentKey must exist to connect to Webchat servers"); + }); - expect(initConfigSpy).toBeCalledWith(expect.objectContaining({ serverUrl, theme: { isLight: true } })); + it("gives warning when unsupported params are passed", () => { + const logger = window.Twilio.getLogger("InitWebChat"); + const warningSpy = jest.spyOn(logger, "warn"); + initWebchat({ deploymentKey: "xyz", someKey: "abc" }); + expect(warningSpy).toBeCalledTimes(1); + expect(warningSpy).toHaveBeenCalledWith("someKey is not supported."); }); - it("triggers expaneded true if appStatus is open", () => { + it("triggers expanded true if appStatus is open", () => { const changeExpandedStatusSpy = jest.spyOn(genericActions, "changeExpandedStatus"); initWebchat({ deploymentKey: "CV000000", appStatus: "open" }); expect(changeExpandedStatusSpy).toHaveBeenCalledWith({ expanded: true }); }); - it("triggers expaneded false if appStatus is closed", () => { + it("triggers expanded false if appStatus is closed", () => { const changeExpandedStatusSpy = jest.spyOn(genericActions, "changeExpandedStatus"); initWebchat({ deploymentKey: "CV000000", appStatus: "closed" }); expect(changeExpandedStatusSpy).toHaveBeenCalledWith({ expanded: false }); }); - it("triggers expaneded false with default appStatus", () => { + it("triggers expanded false with default appStatus", () => { const changeExpandedStatusSpy = jest.spyOn(genericActions, "changeExpandedStatus"); initWebchat({ deploymentKey: "CV000000" }); diff --git a/src/__tests__/logger.test.ts b/src/__tests__/logger.test.ts index f36c29aa..b14b05a9 100644 --- a/src/__tests__/logger.test.ts +++ b/src/__tests__/logger.test.ts @@ -1,4 +1,4 @@ -import { initLogger , getLogger } from "../logger"; +import { initLogger, getLogger } from "../logger"; describe("loggerManager", () => { it("should show a proper message if an invalid log level `DEBUG` is selected", () => { diff --git a/src/__tests__/sessionDataHandler.test.ts b/src/__tests__/sessionDataHandler.test.ts index 3f1bbe71..1b79f67a 100644 --- a/src/__tests__/sessionDataHandler.test.ts +++ b/src/__tests__/sessionDataHandler.test.ts @@ -1,6 +1,6 @@ import fetchMock from "fetch-mock-jest"; -import { sessionDataHandler } from "../sessionDataHandler"; +import { sessionDataHandler, contactBackend } from "../sessionDataHandler"; import WebChatLogger from "../logger"; jest.mock("../logger"); @@ -26,9 +26,44 @@ describe("session data handler", () => { jest.clearAllMocks(); }); - it("should set an endpoint", () => { - sessionDataHandler.setEndpoint("foo"); - expect(sessionDataHandler.getEndpoint()).toBe("foo"); + it("should set the region", () => { + sessionDataHandler.setRegion("Foo"); + expect(sessionDataHandler.getRegion()).toBe("Foo"); + }); + + it("should set the deployment key", () => { + sessionDataHandler.setDeploymentKey("key1"); + expect(sessionDataHandler.getDeploymentKey()).toBe("key1"); + }); + + describe("contactBackend", () => { + beforeEach(() => () => { + sessionDataHandler.setRegion(""); + }); + + afterEach(() => { + sessionDataHandler.setRegion(""); + fetchMock.reset(); + }); + + it("should call correct stage url", async () => { + const mockFetch = Promise.resolve({ ok: true, json: async () => Promise.resolve("okay") }); + const fetchSpy = jest + .spyOn(window, "fetch") + .mockImplementation(async (): Promise => mockFetch as Promise); + sessionDataHandler.setRegion("stage"); + await contactBackend("/Webchat/Tokens/Refresh", { formData: {} }); + expect(fetchSpy.mock.calls[0][0]).toEqual("https://flex-api.stage.twilio.com/v2/Webchat/Tokens/Refresh"); + }); + + it("should call correct prod url", async () => { + const mockFetch = Promise.resolve({ ok: true, json: async () => Promise.resolve("okay") }); + const fetchSpy = jest + .spyOn(window, "fetch") + .mockImplementation(async (): Promise => mockFetch as Promise); + await contactBackend("/Webchat/Tokens/Refresh", { formData: {} }); + expect(fetchSpy.mock.calls[0][0]).toEqual("https://flex-api.twilio.com/v2/Webchat/Tokens/Refresh"); + }); }); describe("fetch and store new session", () => { @@ -196,13 +231,6 @@ describe("session data handler", () => { expect(spyRemove).toHaveBeenCalled(); }); - describe("endpoint", () => { - it("should be able to store endpoint", () => { - sessionDataHandler.setEndpoint("http://localhost:4000"); - expect(sessionDataHandler.getEndpoint()).toEqual("http://localhost:4000"); - }); - }); - describe("contactBackend", () => { it("should", async () => { jest.spyOn(window, "fetch").mockRejectedValueOnce("ForcedFailure"); diff --git a/src/components/PreEngagementFormPhase.tsx b/src/components/PreEngagementFormPhase.tsx index 5f7de13a..084d4ee8 100644 --- a/src/components/PreEngagementFormPhase.tsx +++ b/src/components/PreEngagementFormPhase.tsx @@ -36,7 +36,7 @@ export const PreEngagementFormPhase = () => { dispatch( initSession({ token: data.token, - conversationSid: data.conversationSid + conversationSid: data.conversation_sid }) ); } catch (err) { @@ -97,7 +97,7 @@ export const PreEngagementFormPhase = () => { data-test="pre-engagement-chat-form-query-textarea" value={query} onChange={(e) => dispatch(updatePreEngagementData({ query: e.target.value }))} - onKeyPress={handleKeyPress} + onKeyDown={handleKeyPress} required /> diff --git a/src/components/WebchatWidget.tsx b/src/components/WebchatWidget.tsx index 566ad70a..215715dd 100644 --- a/src/components/WebchatWidget.tsx +++ b/src/components/WebchatWidget.tsx @@ -20,15 +20,10 @@ export function WebchatWidget() { if (data) { try { logger.info("Initializing session."); - dispatch( - initSession({ - token: data.token, - conversationSid: data.conversationSid - }) - ); + dispatch(initSession({ token: data.token, conversationSid: data.conversation_sid })); } catch (e) { // if initSession fails, go to changeEngagement phase - most likely there's something wrong with the store token or conversation sis - logger.error("Something wrong with the store token or conversation sis. Changing engagement phase."); + logger.error("Something wrong with the store token or conversation_sid. Changing engagement phase."); dispatch(changeEngagementPhase({ phase: EngagementPhase.PreEngagementForm })); } } else { diff --git a/src/components/__tests__/PreEngagementFormPhase.test.tsx b/src/components/__tests__/PreEngagementFormPhase.test.tsx index c9a5038d..06f26572 100644 --- a/src/components/__tests__/PreEngagementFormPhase.test.tsx +++ b/src/components/__tests__/PreEngagementFormPhase.test.tsx @@ -9,10 +9,10 @@ import { sessionDataHandler } from "../../sessionDataHandler"; import { store } from "../../store/store"; const token = "token"; -const conversationSid = "sid"; +const conversation_sid = "sid"; jest.mock("../../sessionDataHandler", () => ({ sessionDataHandler: { - fetchAndStoreNewSession: () => ({ token, conversationSid }), + fetchAndStoreNewSession: () => ({ token, conversation_sid }), getRegion: jest.fn() } })); @@ -91,7 +91,7 @@ describe("Pre Engagement Form Phase", () => { fireEvent.submit(formBox); await waitFor(() => { - expect(initAction.initSession).toHaveBeenCalledWith({ token, conversationSid }); + expect(initAction.initSession).toHaveBeenCalledWith({ token, conversationSid: conversation_sid }); }); }); @@ -144,7 +144,7 @@ describe("Pre Engagement Form Phase", () => { const { container } = render(withStore()); const textArea = container.querySelector("textarea") as Element; - fireEvent.keyPress(textArea, { key: "Enter", code: "Enter", charCode: 13, shiftKey: false }); + fireEvent.keyDown(textArea, { key: "Enter", code: "Enter", charCode: 13, shiftKey: false }); expect(fetchAndStoreNewSessionSpy).toHaveBeenCalled(); }); @@ -154,7 +154,7 @@ describe("Pre Engagement Form Phase", () => { const { container } = render(withStore()); const textArea = container.querySelector("textarea") as Element; - fireEvent.keyPress(textArea, { key: "Enter", code: "Enter", charCode: 13, shiftKey: true }); + fireEvent.keyDown(textArea, { key: "Enter", code: "Enter", charCode: 13, shiftKey: true }); expect(fetchAndStoreNewSessionSpy).not.toHaveBeenCalled(); }); diff --git a/src/components/__tests__/WebchatWidget.test.tsx b/src/components/__tests__/WebchatWidget.test.tsx index 379c0c3a..727e2268 100644 --- a/src/components/__tests__/WebchatWidget.test.tsx +++ b/src/components/__tests__/WebchatWidget.test.tsx @@ -51,7 +51,7 @@ afterEach(() => { describe("Webchat Lite", () => { const sessionData = { token: "token", - conversationSid: "sid" + conversation_sid: "sid" }; const region = "stage"; @@ -77,7 +77,10 @@ describe("Webchat Lite", () => { render(); - expect(initSessionSpy).toHaveBeenCalledWith(sessionData); + expect(initSessionSpy).toHaveBeenCalledWith({ + token: sessionData.token, + conversationSid: sessionData.conversation_sid + }); }); it("start pre-engagement form if no pre-existing session data", () => { diff --git a/src/definitions.ts b/src/definitions.ts index e0d4ef68..fa5f72ad 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -1,6 +1,6 @@ export type TokenResponse = { token: string; - conversationSid: string; + conversation_sid: string; identity: string; expiration: string; }; diff --git a/src/index.tsx b/src/index.tsx index 19d49d20..96fa5a5e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -25,29 +25,24 @@ const defaultConfig: ConfigState = { }; const initWebchat = async (userConfig: UserConfig) => { - // eslint-disable-next-line no-warning-comments - // TODO: serverUrl needs to be removed with PR #74 - const validKeys = ["deploymentKey", "region", "theme", "serverUrl", "appStatus"]; + const validKeys = ["deploymentKey", "region", "theme", "appStatus"]; const logger = window.Twilio.getLogger(`InitWebChat`); - // eslint-disable-next-line no-warning-comments - // TODO: Returning from here if no deployment key with PR #74 - if (!userConfig?.deploymentKey) { - logger.error(`deploymentKey must exist to connect to webchat servers`); + if (!userConfig || !userConfig.deploymentKey) { + logger.error(`deploymentKey must exist to connect to Webchat servers`); } - Object.keys(userConfig).forEach((userConfigKey) => { - if (!validKeys.includes(userConfigKey)) { - logger.warn(`${userConfigKey} is not supported.`); + for (const key in userConfig) { + if (!validKeys.includes(key)) { + logger.warn(`${key} is not supported.`); } - }); + } - store.dispatch(changeExpandedStatus({ expanded: userConfig.appStatus === "open" })); - delete userConfig.appStatus; + store.dispatch(changeExpandedStatus({ expanded: userConfig?.appStatus === "open" })); + delete userConfig?.appStatus; const webchatConfig = merge({}, defaultConfig, userConfig); - sessionDataHandler.setEndpoint(webchatConfig.serverUrl); sessionDataHandler.setRegion(webchatConfig.region); sessionDataHandler.setDeploymentKey(webchatConfig.deploymentKey); diff --git a/src/sessionDataHandler.ts b/src/sessionDataHandler.ts index 10390160..c47bcbb2 100644 --- a/src/sessionDataHandler.ts +++ b/src/sessionDataHandler.ts @@ -1,11 +1,9 @@ import { TokenResponse } from "./definitions"; import { generateSecurityHeaders } from "./utils/generateSecurityHeaders"; +import { buildRegionalHost } from "./utils/regionUtil"; export const LOCALSTORAGE_SESSION_ITEM_ID = "TWILIO_WEBCHAT_WIDGET"; -// eslint-disable-next-line no-warning-comments -// TODO: To be removed with PR#46 -let _endpoint = ""; let _region = ""; let _deploymentKey = ""; @@ -13,17 +11,24 @@ type SessionDataStorage = TokenResponse & { loginTimestamp: number | null; }; -async function contactBackend(endpointRoute: string, body: Record = {}): Promise { +export async function contactBackend(endpointRoute: string, body: Record = {}): Promise { + const _endpoint = `https://flex-api${buildRegionalHost(_region)}.twilio.com/v2`; const securityHeaders = await generateSecurityHeaders(); const logger = window.Twilio.getLogger("SessionDataHandler"); + const urlEncodedBody = new URLSearchParams(); + for (const key in body) { + if (body.hasOwnProperty(key)) { + urlEncodedBody.append(key, (body[key] as string).toString()); + } + } const response = await fetch(_endpoint + endpointRoute, { method: "POST", headers: { Accept: "application/json", - "Content-Type": "application/json", + "Content-Type": "application/x-www-form-urlencoded", ...securityHeaders }, - body: JSON.stringify(body) + body: urlEncodedBody.toString() }); if (!response.ok) { @@ -74,14 +79,6 @@ export const sessionDataHandler = { return _deploymentKey; }, - setEndpoint(endpoint: string = "") { - _endpoint = endpoint; - }, - - getEndpoint() { - return _endpoint; - }, - tryResumeExistingSession(): TokenResponse | null { const logger = window.Twilio.getLogger("SessionDataHandler"); logger.info("trying to refresh existing session"); @@ -119,7 +116,8 @@ export const sessionDataHandler = { let newTokenData: TokenResponse; try { - newTokenData = await contactBackend("/getUpdatedToken", { + newTokenData = await contactBackend("/Webchat/Tokens/Refresh", { + DeploymentKey: _deploymentKey, token: storedTokenData.token }); } catch (e) { @@ -145,7 +143,11 @@ export const sessionDataHandler = { let newTokenData; try { - newTokenData = await contactBackend("/initWebchat", { formData }); + newTokenData = await contactBackend("/Webchat/Init", { + DeploymentKey: _deploymentKey, + CustomerFriendlyName: formData?.friendlyName || "Customer", + PreEngagementData: JSON.stringify(formData) + }); } catch (e) { logger.error("No results from server"); throw Error("No results from server"); diff --git a/src/store/actions/listeners/__tests__/clientListeners.test.ts b/src/store/actions/listeners/__tests__/clientListeners.test.ts index 904e3732..52fa9638 100644 --- a/src/store/actions/listeners/__tests__/clientListeners.test.ts +++ b/src/store/actions/listeners/__tests__/clientListeners.test.ts @@ -34,7 +34,7 @@ describe("Client Listeners", () => { it("updates token on tokenAboutToExpire event", async () => { const tokenResponsePayload: TokenResponse = { token: "myToken", - conversationSid: "myConversationSid", + conversation_sid: "myConversationSid", identity: "id", expiration: "never" }; @@ -42,7 +42,7 @@ describe("Client Listeners", () => { type: "ACTION_UPDATE_SESSION_DATA", payload: { token: tokenResponsePayload.token, - conversationSid: tokenResponsePayload.conversationSid + conversationSid: tokenResponsePayload.conversation_sid } }; jest.spyOn(sessionDataHandler, "getUpdatedToken").mockImplementation(async () => ({ diff --git a/src/store/actions/listeners/clientListener.ts b/src/store/actions/listeners/clientListener.ts index 2ba3419b..378c3f8b 100644 --- a/src/store/actions/listeners/clientListener.ts +++ b/src/store/actions/listeners/clientListener.ts @@ -17,13 +17,13 @@ export const initClientListeners = (conversationClient: Client, dispatch: Dispat logger.warn("token about to expire"); const data = await sessionDataHandler.getUpdatedToken(); - if (data?.token && data?.conversationSid) { + if (data?.token && data?.conversation_sid) { await conversationClient.updateToken(data.token); dispatch({ type: ACTION_UPDATE_SESSION_DATA, payload: { token: data.token, - conversationSid: data.conversationSid + conversationSid: data.conversation_sid } }); } diff --git a/src/store/definitions.ts b/src/store/definitions.ts index 0908f0a6..c6fe3b3b 100644 --- a/src/store/definitions.ts +++ b/src/store/definitions.ts @@ -37,10 +37,9 @@ export type SessionState = { }; export type UserConfig = { - serverUrl?: string; deploymentKey: string; region?: string; - appStatus?: "open"; + appStatus?: "open" | "closed"; theme?: { isLight?: boolean; overrides?: Partial; diff --git a/src/utils/regionUtil.test.ts b/src/utils/regionUtil.test.ts index 6ed0727d..6a3f1aac 100644 --- a/src/utils/regionUtil.test.ts +++ b/src/utils/regionUtil.test.ts @@ -1,49 +1,90 @@ -import { parseRegionForConversations } from "./regionUtil"; +import { buildRegionalHost, parseRegionForConversations } from "./regionUtil"; export {}; -describe("parseRegionForConversations(region)", () => { - it.each([ - { - regionPassed: "us1", - regionParsed: "us1" - }, - { - regionPassed: "ie1", - regionParsed: "ie1" - }, - { - regionPassed: "", - regionParsed: "us1" - }, - { - regionPassed: "prod", - regionParsed: "us1" - }, - { - regionPassed: "stage-au1", - regionParsed: "stage-au1" - }, - { - regionPassed: "stage-us1", - regionParsed: "stage-us1" - }, - { - regionPassed: "stage", - regionParsed: "stage-us1" - }, - { - regionPassed: "dev", - regionParsed: "dev-us1" - }, - { - regionPassed: "dev-us1", - regionParsed: "dev-us1" - }, - { - regionPassed: "dev-us2", - regionParsed: "dev-us2" - } - ])("builds correct region for various values", ({ regionPassed, regionParsed }) => { - expect(parseRegionForConversations(regionPassed)).toBe(regionParsed); +describe("Build Regions", () => { + describe("buildRegionalHost(region)", () => { + it.each([ + { + regionPassed: "us1", + regionExpected: "" + }, + { + regionPassed: "ie1", + regionExpected: ".ie1" + }, + { + regionPassed: "", + regionExpected: "" + }, + { + regionPassed: "prod", + regionExpected: "" + }, + { + regionPassed: "stage-au1", + regionExpected: ".stage-au1" + }, + { + regionPassed: "stage-us1", + regionExpected: ".stage" + }, + { + regionPassed: "dev-us1", + regionExpected: ".dev" + }, + { + regionPassed: "dev-us2", + regionExpected: ".dev-us2" + } + ])("builds correct region for various values", ({ regionPassed, regionExpected }) => { + expect(buildRegionalHost(regionPassed)).toBe(regionExpected); + }); + }); + + describe("parseRegionForConversations(region)", () => { + it.each([ + { + regionPassed: "us1", + regionParsed: "us1" + }, + { + regionPassed: "ie1", + regionParsed: "ie1" + }, + { + regionPassed: "", + regionParsed: "us1" + }, + { + regionPassed: "prod", + regionParsed: "us1" + }, + { + regionPassed: "stage-au1", + regionParsed: "stage-au1" + }, + { + regionPassed: "stage-us1", + regionParsed: "stage-us1" + }, + { + regionPassed: "stage", + regionParsed: "stage-us1" + }, + { + regionPassed: "dev", + regionParsed: "dev-us1" + }, + { + regionPassed: "dev-us1", + regionParsed: "dev-us1" + }, + { + regionPassed: "dev-us2", + regionParsed: "dev-us2" + } + ])("builds correct region for various values", ({ regionPassed, regionParsed }) => { + expect(parseRegionForConversations(regionPassed)).toBe(regionParsed); + }); }); }); diff --git a/src/utils/regionUtil.ts b/src/utils/regionUtil.ts index 66996c9d..d69ad78c 100644 --- a/src/utils/regionUtil.ts +++ b/src/utils/regionUtil.ts @@ -1,5 +1,19 @@ -export function parseRegionForConversations(region: string | undefined = "") { - region = region || ""; +export function buildRegionalHost(region: string | undefined = ""): string { + switch (region) { + case "prod": + case "us1": + case "": + return ""; + case "dev-us1": + return ".dev"; + case "stage-us1": + return ".stage"; + default: + return `.${region}`; + } +} + +export function parseRegionForConversations(region: string | undefined = ""): string { switch (region) { case "prod": case "":