concord-consortium/lara

View on GitHub
lara-typescript/src/interactive-api-client/api.spec.ts

Summary

Maintainability
F
5 days
Test Coverage
import { mockIFramePhone, MockPhone } from "../interactive-api-lara-host/mock-iframe-phone";
import * as iframePhone from "iframe-phone";
import * as api from "./api";
import { getClient } from "./client";
import {
  IGetFirebaseJwtResponse,
  IShowAlert,
  IShowDialog,
  IShowLightbox,
  ICloseModal,
  IGetInteractiveListOptions,
  IGetInteractiveSnapshotOptions,
  ILinkedInteractiveStateResponse,
  ITextDecorationInfo,
  IWriteAttachmentRequest,
  IAttachmentUrlResponse
} from "./types";

jest.mock("./in-frame", () => ({
  inIframe: () => true
}));

jest.mock("iframe-phone", () => {
  return mockIFramePhone();
});

const mockedPhone = iframePhone.getIFrameEndpoint() as unknown as MockPhone;

describe("api", () => {
  beforeEach(() => {
    mockedPhone.reset();
    // Initialize client after resetting mock iframe phone.
    getClient();
  });

  it("supports getInitInteractiveMessage", async () => {
    setTimeout(() => {
      mockedPhone.fakeServerMessage({
        type: "initInteractive",
        content: { mode: "runtime", interactiveState: {foo: "bar"}}
      });
    }, 10);
    expect(await api.getInitInteractiveMessage()).toEqual({
      mode: "runtime",
      interactiveState: {foo: "bar"}
    });
    // interactive state shouldn't be dirty after initial load.
    expect(getClient().managedState.interactiveStateDirty).toEqual(false);
  });

  it("supports setInteractiveState and getInteractiveState", (done) => {
    api.setInteractiveState({foo: true});
    expect(api.getInteractiveState()).toEqual({foo: true});
    expect(getClient().managedState.interactiveStateDirty).toEqual(true);
    setTimeout(() => {
      expect(mockedPhone.messages).toEqual([{type: "interactiveState", content: {foo: true}}]);
      expect(getClient().managedState.interactiveStateDirty).toEqual(false);
      done();
    }, api.setInteractiveStateTimeout + 1);
  });

  it("supports setAuthoredState and getAuthoredState", () => {
    api.setAuthoredState({bar: true});
    expect(mockedPhone.messages).toEqual([{type: "authoredState", content: {bar: true}}]);
    expect(api.getAuthoredState()).toEqual({bar: true});
  });

  it("supports setGlobalInteractiveState", () => {
    api.setGlobalInteractiveState({baz: true});
    expect(mockedPhone.messages).toEqual([{type: "interactiveStateGlobal", content: {baz: true}}]);
    expect(api.getGlobalInteractiveState()).toEqual({baz: true});
  });

  it("supports setHeight", () => {
    api.setHeight(123);
    expect(mockedPhone.messages).toEqual([{type: "height", content: 123}]);
  });

  it("supports setHint", () => {
    api.setHint("test hint");
    expect(mockedPhone.messages).toEqual([{type: "hint", content: {text: "test hint"}}]);
  });

  it("supports log", () => {
    api.log("test action", {param1: 1});
    expect(mockedPhone.messages).toEqual([{type: "log", content: {action: "test action", data: {param1: 1}}}]);
  });

  it("can add/remove custom message listener and pass supported messages to setSupportedFeatures", () => {
    const listener = jest.fn();
    api.addCustomMessageListener(listener, { handles: { foo: true } });
    api.setSupportedFeatures({ apiVersion: 1, features: {} } as any);
    expect(api.removeCustomMessageListener()).toBe(true);
  });

  it("supports content decoration", () => {
    const callback = jest.fn();
    api.addDecorateContentListener(callback);
    const content: ITextDecorationInfo = {
      listenerTypes: [{ type: "type" }],
      words: [],
      replace: "",
      wordClass: "word"
    };
    mockedPhone.fakeServerMessage({type: "decorateContent", content});
    api.postDecoratedContentEvent({ type: "type", text: "text" });
    expect(callback).toHaveBeenCalledTimes(1);
    api.removeDecorateContentListener();
    api.postDecoratedContentEvent({ type: "type", text: "text" });
    // verify that listener wasn't called again after removal
    expect(callback).toHaveBeenCalledTimes(1);
  });

  it("supports setSupportedFeatures", () => {
    api.setSupportedFeatures({ interactiveState: true, authoredState: true, aspectRatio: 1 });
    expect(mockedPhone.messages).toEqual([{
      type: "supportedFeatures", content: {
        apiVersion: 1,
        features: { interactiveState: true, authoredState: true, aspectRatio: 1 }
      }
    }]);
  });

  it("supports setNavigation", () => {
    api.setNavigation({ enableForwardNav: true, message: "foo" });
    expect(mockedPhone.messages).toEqual([{
      type: "navigation",
      content: {
        enableForwardNav: true,
        message: "foo"
      }
    }]);
  });

  it("supports getAuthInfo called multiple times", async () => {
    const requestContent = [
      {},
      {},
      {}
    ];
    await testRequestResponse({
      method: api.getAuthInfo,
      requestType: "getAuthInfo",
      requestContent,
      responseType: "authInfo",
      responseContent: [
        {user: "foo"},
        {user: "bar"},
        {user: "baz"}
      ],
      resolvesTo: [
        {user: "foo"},
        {user: "bar"},
        {user: "baz"}
      ]
    });
  });

  describe("getFirebaseJwt", () => {
    it("supports multiple calls", async () => {
      const requestContent: string[] = [
        "foo",
        "bar",
        "baz"
      ];

      // 1 assertion for each requestContent, plus 1 additional assertion
      expect.assertions(requestContent.length + 1);

      mockedPhone.fakeServerMessage({
        type: "initInteractive",
        content: {
          hostFeatures: {
            getFirebaseJwt: {version: "1.0.0"}
          }
        }
      });
      await testRequestResponse({
        method: api.getFirebaseJwt,
        requestType: "getFirebaseJwt",
        requestContent,
        responseType: "firebaseJWT",
        responseContent: [
          // Tokens generated using: https://jwt.io/
          {token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGFpbXMiOnsicGxhdGZvcm1fdXNlcl9pZCI6MX19.uA1QBaqlcsWv7cGIEn9WvhBT1PZW7l1VD28dz9mu-U8"},
          {token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGFpbXMiOnsicGxhdGZvcm1fdXNlcl9pZCI6Mn19.--dC7AzrLHCGENkoGbwtJvst0OEG2IDZmDZSMZG-6D0"},
          {token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGFpbXMiOnsicGxhdGZvcm1fdXNlcl9pZCI6M319.yxGmCe0ZDavxl1NFrVw9-WDhbDFZ6J5hKdhXDeUPkAQ"}
        ],
        resolvesTo: [
          {claims: { claims: { platform_user_id: 1 } }, token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGFpbXMiOnsicGxhdGZvcm1fdXNlcl9pZCI6MX19.uA1QBaqlcsWv7cGIEn9WvhBT1PZW7l1VD28dz9mu-U8"},
          {claims: { claims: { platform_user_id: 2 } }, token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGFpbXMiOnsicGxhdGZvcm1fdXNlcl9pZCI6Mn19.--dC7AzrLHCGENkoGbwtJvst0OEG2IDZmDZSMZG-6D0"},
          {claims: { claims: { platform_user_id: 3 } }, token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGFpbXMiOnsicGxhdGZvcm1fdXNlcl9pZCI6M319.yxGmCe0ZDavxl1NFrVw9-WDhbDFZ6J5hKdhXDeUPkAQ"},
        ]
      });
    });

    it("fails when hostFeatures.getFirebaseJwt is not present", async () => {
      expect.assertions(1);
      mockedPhone.fakeServerMessage({
        type: "initInteractive",
        content: {
        }
      });
      await expect(api.getFirebaseJwt("foo")).rejects.toEqual("getFirebaseJwt not supported by the host environment");
    });

    it("handles errors from getFirebaseJwt", () => {
      const promise = api.getFirebaseJwt("foo");
      const content: IGetFirebaseJwtResponse = {
        requestId: 1,
        response_type: "ERROR",
        message: "it's broke!"
      };
      mockedPhone.fakeServerMessage({type: "firebaseJWT", content});
      expect(promise).rejects.toEqual("it's broke!");
    });

    it("handles incorrect JWTs", () => {
      const promise = api.getFirebaseJwt("foo");
      const content: IGetFirebaseJwtResponse = {
        requestId: 1,
        token: "invalid JWT"
      };
      mockedPhone.fakeServerMessage({type: "firebaseJWT", content});
      expect(promise).rejects.toEqual("Unable to parse JWT Token");
    });
  });

  it("supports interactive state observing", () => {
    const listener = jest.fn();
    api.addInteractiveStateListener(listener);
    getClient().managedState.interactiveState = {foo: 123};
    expect(listener).toHaveBeenCalledWith({foo: 123});
    expect(listener).toHaveBeenCalledTimes(1);

    api.removeInteractiveStateListener(listener);
    getClient().managedState.interactiveState = {bar: 123};
    expect(listener).toHaveBeenCalledTimes(1);
  });

  it("supports authored state observing", () => {
    const listener = jest.fn();
    api.addAuthoredStateListener(listener);
    getClient().managedState.authoredState = {foo: 123};
    expect(listener).toHaveBeenCalledWith({foo: 123});
    expect(listener).toHaveBeenCalledTimes(1);

    api.removeAuthoredStateListener(listener);
    getClient().managedState.authoredState = {bar: 123};
    expect(listener).toHaveBeenCalledTimes(1);
  });

  it("supports global interactive state observing", () => {
    const listener = jest.fn();
    api.addGlobalInteractiveStateListener(listener);
    getClient().managedState.globalInteractiveState = {foo: 123};
    expect(listener).toHaveBeenCalledWith({foo: 123});
    expect(listener).toHaveBeenCalledTimes(1);

    api.removeGlobalInteractiveStateListener(listener);
    getClient().managedState.globalInteractiveState = {bar: 123};
    expect(listener).toHaveBeenCalledTimes(1);
  });

  it("supports linked interactive state observing", () => {
    const listener = jest.fn();
    const options = { interactiveItemId: "interactive_123" };
    api.addLinkedInteractiveStateListener(listener, options);
    expect(mockedPhone.messages[0].type).toEqual("addLinkedInteractiveStateListener");
    expect(mockedPhone.messages[0].content.interactiveItemId).toEqual("interactive_123");
    const listenerId = mockedPhone.messages[0].content.listenerId;
    expect(listenerId).toBeDefined();

    const correctResponse: ILinkedInteractiveStateResponse<any> = {
      listenerId,
      interactiveState: {foo: 123}
    };
    mockedPhone.fakeServerMessage({
      type: "linkedInteractiveState",
      content: correctResponse
    });
    expect(listener).toHaveBeenCalledWith({foo: 123});
    expect(listener).toHaveBeenCalledTimes(1);

    const incorrectResponse: ILinkedInteractiveStateResponse<any> = {
      listenerId: "foo_bar", // wrong listenerId
      interactiveState: {foo: 123}
    };
    mockedPhone.fakeServerMessage({
      type: "linkedInteractiveState",
      content: incorrectResponse
    });
    // Listener should NOT be called.
    expect(listener).toHaveBeenCalledTimes(1);

    api.removeLinkedInteractiveStateListener(listener);
    expect(mockedPhone.messages[1]).toEqual({ type: "removeLinkedInteractiveStateListener", content: { listenerId } });

    mockedPhone.fakeServerMessage({
      type: "linkedInteractiveState",
      content: correctResponse
    });
    // Listener should NOT be called after it's been removed.
    expect(listener).toHaveBeenCalledTimes(1);
  });

  it("should implement showModal [alert]", () => {
    const options: IShowAlert = {
      uuid: "foo",
      type: "alert",
      style: "correct",
      title: "Custom Title",
      text: "Custom message"
    };
    api.showModal(options);
    expect(mockedPhone.messages).toEqual([{ type: "showModal", content: options }]);
  });

  it("should implement showModal [lightbox]", () => {
    const options: IShowLightbox = {
      uuid: "foo",
      type: "lightbox",
      url: "https://concord.org"
    };
    api.showModal(options);
    expect(mockedPhone.messages).toEqual([{ type: "showModal", content: options }]);
  });

  it("should implement getInteractiveList", async () => {
    const requestContent = [
      {scope: "page", supportsSnapshots: true}
    ];
    mockedPhone.fakeServerMessage({
      type: "initInteractive",
      content: { mode: "authoring" },
    });
    await testRequestResponse({
      method: api.getInteractiveList,
      requestType: "getInteractiveList",
      requestContent,
      responseType: "interactiveList",
      responseContent: [
        {interactives: ["abc"]}
      ],
      resolvesTo: [
        {interactives: ["abc"]}
      ]
    });
  });

  it("should reject on getInteractiveList outside of authoring", async () => {
    const request: IGetInteractiveListOptions = {scope: "page", supportsSnapshots: true};
    mockedPhone.fakeServerMessage({
      type: "initInteractive",
      content: { mode: "runtime" }
    });
    expect(api.getInteractiveList(request)).rejects.toBeDefined();
  });

  it("should implement setLinkedInteractives", () => {
    api.setLinkedInteractives({
      linkedInteractives: [
        {id: "interactive_1", label: "one"},
        {id: "interactive_2", label: "two"}
      ],
      linkedState: "interactive_1"
    });
    expect(mockedPhone.messages).toEqual([{type: "setLinkedInteractives", content: {
      linkedInteractives: [
        {id: "interactive_1", label: "one"},
        {id: "interactive_2", label: "two"}
      ],
      linkedState: "interactive_1"
    }}]);
  });

  it("does not yet implement showModal [dialog]", () => {
    const options: IShowDialog = {
      uuid: "foo",
      type: "dialog",
      url: "https://concord.org"
    };
    api.showModal(options);
    expect(mockedPhone.messages).toEqual([{ type: "showModal", content: options }]);
  });

  it("should close a modal alert/lightbox/dialog", () => {
    const options: ICloseModal = { uuid: "foo" };
    api.closeModal(options);
    expect(mockedPhone.messages).toEqual([{ type: "closeModal", content: options }]);
  });

  it("should implement getInteractiveSnapshot", async () => {
    const requestContent: IGetInteractiveSnapshotOptions[] = [
      {interactiveItemId: "interactive_123"}
    ];
    await testRequestResponse({
      method: api.getInteractiveSnapshot,
      requestType: "getInteractiveSnapshot",
      requestContent,
      responseType: "interactiveSnapshot",
      responseContent: [
        {success: true, snapshotUrl: "http://snapshot.com/123"}
      ],
      resolvesTo: [
        {success: true, snapshotUrl: "http://snapshot.com/123"}
      ]
    });
  });

  it("does not yet implement getLibraryInteractiveList", () => {
    expect(() => api.getLibraryInteractiveList({} as any)).toThrow();
  });

  describe("attachments support", () => {
    const globalFetch = global.fetch;
    const fetchMock = jest.fn();
    const kUrlError = "No url for you!";
    const kFetchError = "No fetch for you!";

    beforeEach(() => {
      global.fetch = fetchMock;
      fetchMock.mockReset();
    });

    afterEach(() => {
      global.fetch = globalFetch;
    });

    interface ITestAttachmentResponse {
      requestContent: any[];
      responseContent: any[];
      resolvesTo?: any[];
      rejectsWith?: any[];
    }
    const testWriteAttachmentResponse = (others: ITestAttachmentResponse) =>
      testRequestResponse({
        method: api.writeAttachment,
        requestType: "getAttachmentUrl",
        responseType: "attachmentUrl",
        ...others
      });

    it("can write attachments", async () => {
      const request: Partial<IWriteAttachmentRequest> = { name: "name.ext", content: "foo" };
      const url = "https://concord.org/foo";
      const apiResponse: Partial<IAttachmentUrlResponse> = { url };
      fetchMock.mockReturnValue(Promise.resolve());
      await testWriteAttachmentResponse({
        requestContent: [request],
        responseContent: [apiResponse],
        resolvesTo: [undefined]
      });
      expect(fetchMock.mock.calls[0][0]).toBe(url);
      const fetchOptions = { method: "PUT", headers: { "Content-Type": "text/plain" }, body: "foo" };
      expect(fetchMock.mock.calls[0][1]).toEqual(fetchOptions);
    });

    it("returns error when write attachment api fails", async () => {
      const request: Partial<IWriteAttachmentRequest> = { name: "name.ext", content: "foo" };
      const response: Partial<IAttachmentUrlResponse> = { error: kUrlError };
      fetchMock.mockReturnValue(Promise.resolve());
      await testWriteAttachmentResponse({
        requestContent: [request],
        responseContent: [response],
        rejectsWith: [new Error(kUrlError)]
      });
      expect(fetchMock).not.toHaveBeenCalled();
    });

    it("returns error when write attachment fetch fails", async () => {
      const request: Partial<IWriteAttachmentRequest> = { name: "name.ext", content: "foo" };
      const response: Partial<IAttachmentUrlResponse> = { url: "https://concord.org/foo" };
      fetchMock.mockImplementation(() => { throw new Error(kFetchError); });
      await testWriteAttachmentResponse({
        requestContent: [request],
        responseContent: [response],
        rejectsWith: [new Error(kFetchError)]
      });
      expect(fetchMock.mock.calls[0][0]).toBe(response.url);
      const fetchOptions = { method: "PUT", headers: { "Content-Type": "text/plain" }, body: "foo" };
      expect(fetchMock.mock.calls[0][1]).toEqual(fetchOptions);
    });

    const testReadAttachmentResponse = (others: ITestAttachmentResponse) =>
      testRequestResponse({
        method: api.readAttachment,
        requestType: "getAttachmentUrl",
        responseType: "attachmentUrl",
        ...others
      });

    it("can read attachments", async () => {
      const url = "https://concord.org/foo";
      const apiResponse: Partial<IAttachmentUrlResponse> = { url };
      const fetchResponse = { ok: true, text: () => "foo" };
      fetchMock.mockReturnValue(Promise.resolve(fetchResponse));
      await testReadAttachmentResponse({
        requestContent: [{ name: "name.ext" }],
        responseContent: [apiResponse],
        resolvesTo: [fetchResponse]
      });
      expect(fetchMock.mock.calls[0].length).toBe(1);
      expect(fetchMock.mock.calls[0][0]).toBe(url);
    });

    it("returns error when read attachment api fails", async () => {
      const url = "https://concord.org/foo";
      const apiResponse: Partial<IAttachmentUrlResponse> = { error: kUrlError };
      fetchMock.mockReturnValue(Promise.resolve());
      await testReadAttachmentResponse({
        requestContent: [{ name: "name.ext" }],
        responseContent: [apiResponse],
        rejectsWith: [new Error(kUrlError)]
      });
      expect(fetchMock).not.toHaveBeenCalled();
    });

    it("returns error when read attachment fetch fails", async () => {
      const url = "https://concord.org/foo";
      const apiResponse: Partial<IAttachmentUrlResponse> = { url };
      fetchMock.mockImplementation(() => { throw new Error(kFetchError); });
      await testReadAttachmentResponse({
        requestContent: [{ name: "name.ext" }],
        responseContent: [apiResponse],
        rejectsWith: [new Error(kFetchError)]
      });
      expect(fetchMock.mock.calls[0].length).toBe(1);
      expect(fetchMock.mock.calls[0][0]).toBe(url);
    });

    it("can return attachment urls", async () => {
      const url = "https://concord.org/foo";
      const apiResponse: Partial<IAttachmentUrlResponse> = { url };
      await testRequestResponse({
        method: api.getAttachmentUrl,
        requestType: "getAttachmentUrl",
        requestContent: [{ name: "name.ext" }],
        responseType: "attachmentUrl",
        responseContent: [apiResponse],
        resolvesTo: [url]
      });
    });

    it("returns error when request for attachment urls fails", async () => {
      const url = "https://concord.org/foo";
      const apiResponse: Partial<IAttachmentUrlResponse> = { error: kUrlError };
      await testRequestResponse({
        method: api.getAttachmentUrl,
        requestType: "getAttachmentUrl",
        requestContent: [{ name: "name.ext" }],
        responseType: "attachmentUrl",
        responseContent: [apiResponse],
        rejectsWith: [new Error(kUrlError)]
      });
    });
  });

  describe("report item support", () => {
    it("supports addGetReportItemAnswerListener and removeGetReportItemAnswerListener", () => {
      const listener = jest.fn();
      api.addGetReportItemAnswerListener(listener);
      mockedPhone.fakeServerMessage({type: "getReportItemAnswer" });
      expect(listener).toHaveBeenCalledTimes(1);

      api.removeGetReportItemAnswerListener();
      mockedPhone.fakeServerMessage({type: "getReportItemAnswer" });
      expect(listener).toHaveBeenCalledTimes(1);
    });

    it("supports sendReportItemAnswer", () => {
      const mockAnswer: any = { foo: 1 };
      api.sendReportItemAnswer(mockAnswer);
      expect(mockedPhone.messages[0]).toEqual({ type: "reportItemAnswer", content: mockAnswer });
    });

    it("supports notifyReportItemClientReady", () => {
      const mockMetadata: any = { foo: 1 };
      api.notifyReportItemClientReady(mockMetadata);
      expect(mockedPhone.messages[0]).toEqual({ type: "reportItemClientReady", content: mockMetadata });
    });
  });
});

// helpers

interface IRequestResponseOptions {
  method: (options: any) => Promise<any>;
  requestType: string;
  requestContent: any[];
  responseType: string;
  responseContent: any[];
  resolvesTo?: any[];
  rejectsWith?: any[];
}

const testRequestResponse = async (options: IRequestResponseOptions) => {
  const startListeners = mockedPhone.numListeners;
  const requestIds: number[] = [];
  const promises: Array<Promise<any>> = [];

  options.requestContent.forEach((rc, index) => {
    const requestId = index + 1;
    requestIds.push(requestId);
    promises.push(options.method(options.requestContent[index]));
  });

  // fake out of order responses to ensure requests are routed correctly
  requestIds.sort(() => Math.random() - 0.5);

  // Why responses are sent with some delay?
  // Note that some client functions might add message listeners with a delay. E.g. getFirebaseJwt adds response
  // listener AFTER it gets init interactive msg / current mode. In tests everything is mocked, so 10ms is enough to
  // make sure these responses are triggered after listeners. Generally, this function doesn't seem to be the best
  // option, but it's already used by multiple tests. It seems that these confusing parts could be replaced by more
  // advanced iframe-phone mock.
  setTimeout(() => {
    // in case you want to see the order...
    // console.log(`${options.responseType} random response order: ${requestIds.join(",")}`);
    requestIds.forEach(requestId => {
      const content = { requestId, ...options.responseContent[requestId - 1] };
      mockedPhone.fakeServerMessage({type: options.responseType, content});
    });
  }, 10);

  await Promise.all(promises.map(async (promise, index) => {
    if ((options.rejectsWith?.length || 0) > index) {
      await expect(promise).rejects.toEqual(options.rejectsWith?.[index]);
    }
    else if ((options.resolvesTo?.length || 0) > index) {
      await expect(promise).resolves.toEqual(options.resolvesTo?.[index]);
    }
  }));

  mockedPhone.removeListener(options.requestType);

  // it removes the listener
  expect(mockedPhone.numListeners).toEqual(startListeners);
};