From 60c8f9a0d5a68e1494f037bae128eb30ff90f654 Mon Sep 17 00:00:00 2001 From: Kiwi Date: Sat, 27 Apr 2019 01:04:16 +0200 Subject: [PATCH] [next] Tests for new story filter and combo box (#2288) * test: add tests for changes in stories page * test: search box and moderate specific story * fix: remaining snapshot * fix: test --- .../admin/containers/EntryContainer.tsx | 21 +- .../routes/moderate/components/Moderate.tsx | 2 +- .../__snapshots__/Moderate.spec.tsx.snap | 2 +- .../__snapshots__/moderate.spec.tsx.snap | 506 +++++++++++++----- .../admin/test/moderate/moderate.spec.tsx | 262 ++++++++- .../admin/test/stories/stories.spec.tsx | 46 +- .../framework/lib/bootstrap/TalkContext.tsx | 4 + .../testHelpers/TransitionControl.tsx | 43 ++ .../testHelpers/createTestRenderer.tsx | 7 +- .../client/framework/testHelpers/index.ts | 4 + src/core/client/test/mocks.ts | 17 + src/core/common/types.ts | 2 +- src/types/found.d.ts | 7 + 13 files changed, 764 insertions(+), 159 deletions(-) create mode 100644 src/core/client/framework/testHelpers/TransitionControl.tsx create mode 100644 src/types/found.d.ts diff --git a/src/core/client/admin/containers/EntryContainer.tsx b/src/core/client/admin/containers/EntryContainer.tsx index f04dd40c3..183a3ad97 100644 --- a/src/core/client/admin/containers/EntryContainer.tsx +++ b/src/core/client/admin/containers/EntryContainer.tsx @@ -1,9 +1,11 @@ import { BrowserProtocol, queryMiddleware } from "farce"; -import { createFarceRouter, createRender } from "found"; +import { createFarceRouter, ElementsRenderer } from "found"; import { Resolver } from "found-relay"; - import React, { StatelessComponent } from "react"; +import TransitionControl from "talk-framework/testHelpers/TransitionControl"; + import { TalkContextConsumer } from "talk-framework/lib/bootstrap/TalkContext"; + import routeConfig from "../routeConfig"; import NotFound from "../routes/NotFound"; @@ -11,11 +13,16 @@ const Router = createFarceRouter({ historyProtocol: new BrowserProtocol(), historyMiddlewares: [queryMiddleware], routeConfig, - render: createRender({ - renderError: ({ error }) => ( -
{error.status === 404 ? : "Error"}
- ), - }), + renderReady: ({ elements }) => ( + <> + + {// this enables router transition control when writing tests. + process.env.NODE_ENV === "test" && } + + ), + renderError: ({ error }) => ( +
{error.status === 404 ? : "Error"}
+ ), }); const EntryContainer: StatelessComponent = () => ( diff --git a/src/core/client/admin/routes/moderate/components/Moderate.tsx b/src/core/client/admin/routes/moderate/components/Moderate.tsx index 49fe6d263..800383641 100644 --- a/src/core/client/admin/routes/moderate/components/Moderate.tsx +++ b/src/core/client/admin/routes/moderate/components/Moderate.tsx @@ -27,7 +27,7 @@ const Moderate: StatelessComponent = ({ }) => (
- + - -
-`; - exports[`rejected queue accepts comment in rejected queue: count should be 1 1`] = ` `; +exports[`search bar all stories active search with no results 1`] = ` +
+
+
+
+
+
+
+
+ +
+ Stories: +
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+
+
+`; + +exports[`search bar all stories active search with too many results 1`] = ` +
  • + + + See all results + + + +
  • +`; + +exports[`search bar all stories renders search bar 1`] = ` +
    +
    +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    + +
    +
    +`; + exports[`single comment view accepts single comment 1`] = `
    `; + +exports[`tab bar renders tab bar (empty queues) 1`] = ` + +`; diff --git a/src/core/client/admin/test/moderate/moderate.spec.tsx b/src/core/client/admin/test/moderate/moderate.spec.tsx index d60f74abc..1b30c4d44 100644 --- a/src/core/client/admin/test/moderate/moderate.spec.tsx +++ b/src/core/client/admin/test/moderate/moderate.spec.tsx @@ -12,20 +12,27 @@ import { createQueryResolverStub, createResolversStub, CreateTestRendererParams, + findParentWithType, replaceHistoryLocation, toJSON, + wait, waitForElement, waitUntilThrow, within, } from "talk-framework/testHelpers"; +import { noop } from "lodash"; +import { ReactTestRenderer } from "react-test-renderer"; import create from "../create"; import { emptyModerationQueues, emptyRejectedComments, + emptyStories, rejectedComments, reportedComments, settings, + stories, + storyConnection, users, } from "../fixtures"; @@ -38,7 +45,7 @@ beforeEach(async () => { async function createTestRenderer( params: CreateTestRendererParams = {} ) { - const { testRenderer } = create({ + const { testRenderer, context } = create({ ...params, resolvers: pureMerge( createResolversStub({ @@ -58,21 +65,236 @@ async function createTestRenderer( } }, }); - return testRenderer; + return { testRenderer, context }; } -describe("navigation bar", () => { - it("renders navigation bar (empty queues)", async () => { - const testRenderer = await createTestRenderer(); +describe("search bar", () => { + const openSearchBar = async (testRenderer: ReactTestRenderer) => { + const searchBar = await waitForElement(() => + within(testRenderer.root).getByTestID("moderate-searchBar-container") + ); + const textField = within(searchBar).getByLabelText( + "Search or jump to story..." + ); + const form = findParentWithType(textField, "form")!; + textField.props.onFocus({}); + return { searchBar, textField, form }; + }; + + describe("all stories", () => { + it("renders search bar", async () => { + const { testRenderer } = await createTestRenderer(); + const searchBar = await waitForElement(() => + within(testRenderer.root).getByTestID("moderate-searchBar-container") + ); + expect(within(searchBar).toJSON()).toMatchSnapshot(); + }); + + describe("active", () => { + it("search with no results", async () => { + const query = "InterestingStory"; + const { testRenderer } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + stories: ({ variables }) => { + expectAndFail(variables.query).toBe(query); + return emptyStories; + }, + }, + }), + }); + const { searchBar, textField, form } = await openSearchBar( + testRenderer + ); + expect(within(searchBar).toJSON()).toMatchSnapshot(); + + // Search for sth. + textField.props.onChange(query); + form.props.onSubmit(); + + // Ensure no results message is shown. + await wait(() => + within(searchBar).getByText("No results", { exact: false }) + ); + + // Blurring should close the listbox. + textField.props.onBlur({}); + expect(within(searchBar).queryByText("No results")).toBeNull(); + }); + it("search with actual results", async () => { + const query = "InterestingStory"; + const { + testRenderer, + context: { transitionControl }, + } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + stories: ({ variables }) => { + expectAndFail(variables.query).toBe(query); + return storyConnection; + }, + }, + }), + }); + transitionControl.allowTransition = false; + const { searchBar, textField, form } = await openSearchBar( + testRenderer + ); + + // Search for sth. + textField.props.onChange(query); + form.props.onSubmit(); + + const story = storyConnection.edges[0].node; + + // Find the story in the search results. + const storyOption = findParentWithType( + await waitForElement(() => + within(searchBar).getByText(story.metadata!.title!, { + exact: false, + }) + ), + "li" + )!; + + // Go to story. + storyOption.props.onClick({ button: 0, preventDefault: noop }); + + // Expect a routing request was made to the right url. + expect(transitionControl.history[0].pathname).toBe( + `/admin/moderate/${story.id}` + ); + }); + it("search with too many results", async () => { + const query = "InterestingStory"; + const { + testRenderer, + context: { transitionControl }, + } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + stories: ({ variables }) => { + expectAndFail(variables.query).toBe(query); + return pureMerge(storyConnection, { + pageInfo: { hasNextPage: true }, + }); + }, + }, + }), + }); + transitionControl.allowTransition = false; + const { searchBar, textField, form } = await openSearchBar( + testRenderer + ); + + // Search for sth. + textField.props.onChange(query); + form.props.onSubmit(); + + // Find see all options in the search results. + const seeAllOption = findParentWithType( + await waitForElement(() => + within(searchBar).getByText("See all results", { exact: false }) + ), + "li" + )!; + + expect(within(seeAllOption).toJSON()).toMatchSnapshot(); + + // Go to story. + seeAllOption.props.onClick({ button: 0, preventDefault: noop }); + + // Expect a routing request was made to the right url. + expect(transitionControl.history[0].pathname).toBe("/admin/stories"); + expect(transitionControl.history[0].search).toBe(`?q=${query}`); + }); + }); + }); + describe("specified story", () => { + beforeEach(() => { + replaceHistoryLocation( + `http://localhost/admin/moderate/${stories[0].id}` + ); + }); + it("renders search bar", async () => { + const { testRenderer } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + story: () => stories[0], + }, + }), + }); + const searchBar = await waitForElement(() => + within(testRenderer.root).getByTestID("moderate-searchBar-container") + ); + const textField = within(searchBar).getByLabelText( + "Search or jump to story..." + ); + expect(textField.props.placeholder).toBe(stories[0].metadata!.title); + }); + it("shows moderate all option", async () => { + const { + testRenderer, + context: { transitionControl }, + } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + story: () => stories[0], + }, + }), + }); + transitionControl.allowTransition = false; + const { searchBar } = await openSearchBar(testRenderer); + + // Find see all options in the search results. + const moderateAllOptions = findParentWithType( + await waitForElement(() => + within(searchBar).getByText("Moderate all", { exact: false }) + ), + "li" + )!; + + // Activate moderate all. + moderateAllOptions.props.onClick({ button: 0, preventDefault: noop }); + + // Expect a routing request was made to the right url. + expect(transitionControl.history[0].pathname).toBe("/admin/moderate"); + }); + }); +}); + +describe("tab bar", () => { + it("renders tab bar (empty queues)", async () => { + const { testRenderer } = await createTestRenderer(); const { getByTestID } = within(testRenderer.root); await waitForElement(() => getByTestID("moderate-container")); - expect(toJSON(getByTestID("moderate-subBar-container"))).toMatchSnapshot(); + expect(toJSON(getByTestID("moderate-tabBar-container"))).toMatchSnapshot(); + }); +}); + +describe("moderating specific story", () => { + it("passes storyID to the endpoints", async () => { + replaceHistoryLocation(`http://localhost/admin/moderate/${stories[0].id}`); + await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + moderationQueues: ({ variables }) => { + expectAndFail(variables.storyID).toBe(stories[0].id); + return emptyModerationQueues; + }, + comments: ({ variables }) => { + expectAndFail(variables.storyID).toBe(stories[0].id); + return emptyRejectedComments; + }, + }, + }), + }); }); }); describe("reported queue", () => { it("renders empty reported queue", async () => { - const testRenderer = await createTestRenderer(); + const { testRenderer } = await createTestRenderer(); const { getByTestID } = within(testRenderer.root); await waitForElement(() => getByTestID("moderate-container")); @@ -81,14 +303,14 @@ describe("reported queue", () => { it("renders empty pending queue", async () => { replaceHistoryLocation("http://localhost/admin/moderate/pending"); - const testRenderer = await createTestRenderer(); + const { testRenderer } = await createTestRenderer(); const { getByText } = within(testRenderer.root); await waitForElement(() => getByText("no more pending", { exact: false })); }); it("renders empty unmoderated queue", async () => { replaceHistoryLocation("http://localhost/admin/moderate/unmoderated"); - const testRenderer = await createTestRenderer(); + const { testRenderer } = await createTestRenderer(); const { getByText } = within(testRenderer.root); await waitForElement(() => getByText("comments have been moderated", { exact: false }) @@ -97,7 +319,7 @@ describe("reported queue", () => { it("renders empty rejected queue", async () => { replaceHistoryLocation("http://localhost/admin/moderate/rejected"); - const testRenderer = await createTestRenderer(); + const { testRenderer } = await createTestRenderer(); const { getByText } = within(testRenderer.root); await waitForElement(() => getByText("no rejected comments", { exact: false }) @@ -105,7 +327,7 @@ describe("reported queue", () => { }); it("renders reported queue with comments", async () => { - const testRenderer = await createTestRenderer({ + const { testRenderer } = await createTestRenderer({ resolvers: createResolversStub({ Query: { moderationQueues: () => @@ -191,7 +413,7 @@ describe("reported queue", () => { }, }); - const testRenderer = await createTestRenderer({ + const { testRenderer } = await createTestRenderer({ resolvers: createResolversStub({ Query: { moderationQueues: () => moderationQueuesStub, @@ -278,7 +500,7 @@ describe("reported queue", () => { }, }); - const testRenderer = await createTestRenderer({ + const { testRenderer } = await createTestRenderer({ resolvers: createResolversStub({ Query: { moderationQueues: () => moderationQueuesStub, @@ -333,7 +555,7 @@ describe("reported queue", () => { }; }); - const testRenderer = await createTestRenderer({ + const { testRenderer } = await createTestRenderer({ resolvers: createResolversStub({ Query: { moderationQueues: () => @@ -400,7 +622,7 @@ describe("rejected queue", () => { }); it("renders rejected queue with comments", async () => { - const testRenderer = await createTestRenderer({ + const { testRenderer } = await createTestRenderer({ resolvers: createResolversStub({ Query: { comments: ({ variables }) => { @@ -435,7 +657,7 @@ describe("rejected queue", () => { }); it("renders rejected queue with comments and load more", async () => { - const testRenderer = await createTestRenderer({ + const { testRenderer } = await createTestRenderer({ resolvers: createResolversStub({ Query: { comments: ({ variables, callCount }) => { @@ -540,7 +762,7 @@ describe("rejected queue", () => { }; }); - const testRenderer = await createTestRenderer({ + const { testRenderer } = await createTestRenderer({ resolvers: createResolversStub({ Query: { comments: ({ variables }) => { @@ -613,7 +835,7 @@ describe("single comment view", () => { }); it("renders single comment view", async () => { - const testRenderer = await createTestRenderer({ + const { testRenderer } = await createTestRenderer({ resolvers: { Query: { comment: commentStub, @@ -644,7 +866,7 @@ describe("single comment view", () => { }; }); - const testRenderer = await createTestRenderer({ + const { testRenderer } = await createTestRenderer({ resolvers: { Query: { comment: commentStub, @@ -683,7 +905,7 @@ describe("single comment view", () => { }; }); - const testRenderer = await createTestRenderer({ + const { testRenderer } = await createTestRenderer({ resolvers: { Query: { comment: commentStub, diff --git a/src/core/client/admin/test/stories/stories.spec.tsx b/src/core/client/admin/test/stories/stories.spec.tsx index 305ff2452..c665a9dce 100644 --- a/src/core/client/admin/test/stories/stories.spec.tsx +++ b/src/core/client/admin/test/stories/stories.spec.tsx @@ -1,3 +1,4 @@ +import { noop } from "lodash"; import TestRenderer from "react-test-renderer"; import { pureMerge } from "talk-common/utils"; @@ -36,7 +37,7 @@ beforeEach(async () => { async function createTestRenderer( params: CreateTestRendererParams = {} ) { - const { testRenderer } = create({ + const { testRenderer, context } = create({ ...params, resolvers: pureMerge( createResolversStub({ @@ -62,7 +63,7 @@ async function createTestRenderer( const container = await waitForElement(() => within(testRenderer.root).getByTestID("stories-container") ); - return { testRenderer, container }; + return { testRenderer, container, context }; } it("renders stories", async () => { @@ -81,6 +82,26 @@ it("renders empty stories", async () => { expect(within(container).toJSON()).toMatchSnapshot(); }); +it("goes to moderation when clicking on title", async () => { + const { + container, + context: { transitionControl }, + } = await createTestRenderer(); + + // Prevent router transitions. + transitionControl.allowTransition = false; + + const story = storyConnection.edges[0].node; + within(container) + .getByText(story.metadata!.title!) + .props.onClick({ button: 0, preventDefault: noop }); + + // Expect a routing request was made to the right url. + expect(transitionControl.history[0].pathname).toBe( + `/admin/moderate/${story.id}` + ); +}); + it("filter by status", async () => { const { container } = await createTestRenderer({ resolvers: createResolversStub({ @@ -268,3 +289,24 @@ it("filter by search", async () => { within(container).getByText("could not find any", { exact: false }) ); }); + +it("use searchFilter from url", async () => { + const searchFilter = "CandyMountain"; + replaceHistoryLocation(`http://localhost/admin/stories?q=${searchFilter}`); + const { container } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + stories: ({ variables }) => { + expectAndFail(variables.query).toBe(searchFilter); + return emptyStories; + }, + }, + }), + }); + + const searchField = within(container).getByLabelText( + "Search by story title", + { exact: false } + ); + expect(searchField.props.value).toBe(searchFilter); +}); diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx index 49b2479d6..16a7c54fb 100644 --- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx +++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx @@ -11,6 +11,7 @@ import { BrowserInfo } from "talk-framework/lib/browserInfo"; import { PostMessageService } from "talk-framework/lib/postMessage"; import { RestClient } from "talk-framework/lib/rest"; import { PromisifiedStorage } from "talk-framework/lib/storage"; +import { TransitionControlData } from "talk-framework/testHelpers"; import { UIContext } from "talk-ui/components"; import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; @@ -62,6 +63,9 @@ export interface TalkContext { /** Clear session data. */ clearSession: () => Promise; + + /** Controls router transitions (for tests) */ + transitionControl?: TransitionControlData; } export const TalkReactContext = React.createContext({} as any); diff --git a/src/core/client/framework/testHelpers/TransitionControl.tsx b/src/core/client/framework/testHelpers/TransitionControl.tsx new file mode 100644 index 000000000..29eaa83a7 --- /dev/null +++ b/src/core/client/framework/testHelpers/TransitionControl.tsx @@ -0,0 +1,43 @@ +import { Location, Match, Router, withRouter } from "found"; +import React, { useEffect } from "react"; +import { withContext } from "talk-framework/lib/bootstrap"; + +interface Props { + /** router is injected by `withRouter` HOC */ + router: Router; + /** match is injected by `withRouter` HOC */ + match: Match; + /** transitionControl is injected by `withContext` HOC */ + transitionControl: TransitionControlData | undefined; +} + +/** + * TransitionControlData allows controlling router transition. + */ +export interface TransitionControlData { + /** allowTransition if set to false, will prevent router transitions from happening. */ + allowTransition: boolean; + /** history contains all records of router transition requests. */ + history: Location[]; +} + +const TransitionControl: React.FunctionComponent = props => { + useEffect(() => { + return props.router.addTransitionHook(location => { + if (props.transitionControl) { + props.transitionControl.history.push(location); + if (!props.transitionControl.allowTransition) { + return false; + } + } + return; + }); + }, []); + return null; +}; + +const enhanced = withContext(({ transitionControl }) => ({ + transitionControl, +}))(withRouter(TransitionControl)); + +export default enhanced; diff --git a/src/core/client/framework/testHelpers/createTestRenderer.tsx b/src/core/client/framework/testHelpers/createTestRenderer.tsx index c1f358ea4..106a72769 100644 --- a/src/core/client/framework/testHelpers/createTestRenderer.tsx +++ b/src/core/client/framework/testHelpers/createTestRenderer.tsx @@ -6,6 +6,7 @@ import React from "react"; import TestRenderer, { ReactTestRenderer } from "react-test-renderer"; import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime"; +import { RequireProperty } from "talk-common/types"; import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap"; import { PostMessageService } from "talk-framework/lib/postMessage"; import { RestClient } from "talk-framework/lib/rest"; @@ -77,7 +78,7 @@ export default function createTestRenderer< }, }); - const context: TalkContext = { + const context: RequireProperty = { relayEnvironment: environment, locales: ["en-US"], localeBundles: [ @@ -94,6 +95,10 @@ export default function createTestRenderer< uuidGenerator: createUUIDGenerator(), eventEmitter: new EventEmitter2({ wildcard: true, maxListeners: 20 }), clearSession: () => Promise.resolve(), + transitionControl: { + allowTransition: true, + history: [], + }, }; let testRenderer: ReactTestRenderer; diff --git a/src/core/client/framework/testHelpers/index.ts b/src/core/client/framework/testHelpers/index.ts index b050d1c7b..7a9b9d109 100644 --- a/src/core/client/framework/testHelpers/index.ts +++ b/src/core/client/framework/testHelpers/index.ts @@ -36,3 +36,7 @@ export { CreateTestRendererParams, } from "./createTestRenderer"; export { default as createResolversStub } from "./createResolversStub"; +export { + TransitionControlData, + default as TransitionControl, +} from "./TransitionControl"; diff --git a/src/core/client/test/mocks.ts b/src/core/client/test/mocks.ts index d41224454..adfd30d08 100644 --- a/src/core/client/test/mocks.ts +++ b/src/core/client/test/mocks.ts @@ -9,3 +9,20 @@ jest.mock("react-dom", () => ({ ...require.requireActual("react-dom"), createPortal: (node: any) => node, })); + +jest.mock("popper.js", () => { + const PopperJS = require.requireActual("popper.js"); + + return class Popper { + public static placements = PopperJS.placements; + + constructor() { + return { + // tslint:disable-next-line:no-empty + destroy: () => {}, + // tslint:disable-next-line:no-empty + scheduleUpdate: () => {}, + }; + } + }; +}); diff --git a/src/core/common/types.ts b/src/core/common/types.ts index 973cd7e05..dea60e9b0 100644 --- a/src/core/common/types.ts +++ b/src/core/common/types.ts @@ -7,7 +7,7 @@ export type Overwrite = Pick> & U; export type Sub = Pick>; -export type RequireProperty = Omit, P> & +export type RequireProperty = Omit & Required>; /** diff --git a/src/types/found.d.ts b/src/types/found.d.ts new file mode 100644 index 000000000..02484ba43 --- /dev/null +++ b/src/types/found.d.ts @@ -0,0 +1,7 @@ +import { FunctionComponent } from "react"; + +declare module "found" { + const ElementsRenderer: FunctionComponent<{ + elements: ReactElementOrGroup[]; + }>; +}