From a6d9ade69e0a1ebb5bc98a5e8a6e13d6c7eb48de Mon Sep 17 00:00:00 2001 From: Mcat12 Date: Thu, 2 May 2019 21:05:36 -0700 Subject: [PATCH] Add EnableDisable tests Signed-off-by: Mcat12 --- src/components/common/EnableDisable.tsx | 41 +- .../common/__tests__/EnableDisable.test.tsx | 363 ++++++++++++++++++ src/routes.tsx | 4 +- 3 files changed, 399 insertions(+), 9 deletions(-) create mode 100644 src/components/common/__tests__/EnableDisable.test.tsx diff --git a/src/components/common/EnableDisable.tsx b/src/components/common/EnableDisable.tsx index 266aba7..467e9cb 100644 --- a/src/components/common/EnableDisable.tsx +++ b/src/components/common/EnableDisable.tsx @@ -35,6 +35,10 @@ import { export interface EnableDisableProps extends WithNamespaces { status: Status; refresh: (data?: ApiStatus) => void; + onSetStatus: ( + action: StatusAction, + time?: number + ) => Promise; } export interface EnableDisableState { @@ -44,7 +48,10 @@ export interface EnableDisableState { customMultiplier: number; } -class EnableDisable extends Component { +export class EnableDisable extends Component< + EnableDisableProps, + EnableDisableState +> { state: EnableDisableState = { processing: false, customModalShown: false, @@ -82,17 +89,26 @@ class EnableDisable extends Component { } // Only allow one status update at a time - this.setState({ processing: true }); + this.toggleProcessing(); // Send the status change request - this.updateHandler = makeCancelable(api.setStatus(action, time)); + this.updateHandler = makeCancelable(this.props.onSetStatus(action, time)); this.updateHandler.promise // Refresh once we get a good response .then(() => this.props.refresh({ status: this.getStatusFromAction(action) }) ) - // Even if it failed, allow new status changes - .finally(() => this.setState({ processing: false })); + // Allow new status changes when finished + .then(this.toggleProcessing) + .catch(e => { + // Ignore canceled requests + if (e.isCanceled) { + return; + } + + // Even if it failed, allow new status changes + this.toggleProcessing(); + }); }; componentWillUnmount() { @@ -108,6 +124,13 @@ class EnableDisable extends Component { this.setState({ customModalShown: !this.state.customModalShown }); }; + /** + * Toggle the processing flag + */ + toggleProcessing = () => { + this.setState(prevState => ({ processing: !prevState.processing })); + }; + /** * Submit the request to disable blocking for the custom time that the user * input. @@ -228,10 +251,14 @@ class EnableDisable extends Component { export const TranslatedEnableDisable = withNamespaces("common")(EnableDisable); -export default () => ( +export const EnableDisableContainer = () => ( {({ status, refresh }) => ( - + )} ); diff --git a/src/components/common/__tests__/EnableDisable.test.tsx b/src/components/common/__tests__/EnableDisable.test.tsx new file mode 100644 index 0000000..028168f --- /dev/null +++ b/src/components/common/__tests__/EnableDisable.test.tsx @@ -0,0 +1,363 @@ +/* Pi-hole: A black hole for Internet advertisements + * (c) 2019 Pi-hole, LLC (https://pi-hole.net) + * Network-wide ad blocking via your own hardware. + * + * Web Interface + * EnableDisable component test + * + * This file is copyright under the latest version of the EUPL. + * Please see LICENSE file for your rights under this license. */ + +import { mount, shallow, ShallowWrapper } from "enzyme"; +import { + EnableDisable, + EnableDisableContainer, + EnableDisableProps, + EnableDisableState, + TranslatedEnableDisable +} from "../EnableDisable"; +import React, { MouseEvent } from "react"; +import { StatusContext, StatusContextType } from "../context/StatusContext"; +import NavButton from "../NavButton"; +import NavDropdown from "../NavDropdown"; +import { Form, Input, Modal, ModalFooter, ModalHeader } from "reactstrap"; + +const tick = global.tick; + +type EnableDisableWrapper = ShallowWrapper< + EnableDisableProps, + EnableDisableState, + EnableDisable +>; + +describe("EnableDisableContainer", () => { + it("should use context", () => { + const context: StatusContextType = { + status: "unknown", + refresh: jest.fn() + }; + + // Using mount here as there are issues with passing functions via context + // when using shallow (the passed in value is not equal to the received + // value) + const wrapper = mount( + + + + ); + + const props = wrapper.find(EnableDisable).props(); + + expect(props.status).toEqual(context.status); + expect(props.refresh).toEqual(context.refresh); + }); +}); + +describe("EnableDisable", () => { + describe("API calls", () => { + /** + * Test clicking on a button and expecting it to call setStatus + * + * @param initialStatus The initial status + * @param buttonIndex The index of the button to click + * @param setStatusArgs The arguments that are expected for setStatus + * @param expectedStatus The expected status given to to refresh function + */ + const testCall = async ( + initialStatus: Status, + buttonIndex: number, + setStatusArgs: Array, + expectedStatus: any + ) => { + const setStatus = jest.fn(() => Promise.resolve({ status: "success" })); + const refresh = jest.fn(); + + const wrapper = shallow( + + ); + + wrapper + .find(NavButton) + .at(buttonIndex) + .props() + .onClick({} as MouseEvent); + + // Wait for the setStatus promise to resolve + await tick(); + + expect(setStatus).toHaveBeenCalledWith(...setStatusArgs); + expect(refresh).toHaveBeenCalledWith({ status: expectedStatus }); + }; + + it("calls the API to enable when the enable button is clicked", async () => { + await testCall("disabled", 0, ["enable", undefined], "enabled"); + }); + + it("calls the API to disable when the disable permanently button is clicked", async () => { + await testCall("enabled", 0, ["disable", undefined], "disabled"); + }); + + it("calls the API to disable when the 10 second button is clicked", async () => { + await testCall("enabled", 1, ["disable", 10], "disabled"); + }); + + it("calls the API to disable when the 30 second button is clicked", async () => { + await testCall("enabled", 2, ["disable", 30], "disabled"); + }); + + it("calls the API to disable when the 5 minute button is clicked", async () => { + await testCall("enabled", 3, ["disable", 5 * 60], "disabled"); + }); + + it("waits for the first call to finish before sending another", () => { + const setStatus = jest.fn(() => Promise.resolve({ status: "success" })); + + const wrapper: EnableDisableWrapper = shallow( + + ); + + wrapper.setState({ processing: true }); + + wrapper + .find(NavButton) + .props() + .onClick({} as MouseEvent); + + expect(setStatus).not.toHaveBeenCalled(); + }); + + it("cancels an in-flight request when unmounting", async () => { + const setStatus = jest.fn(() => Promise.resolve({ status: "success" })); + const refresh = jest.fn(); + + const wrapper = shallow( + + ); + + wrapper + .find(NavButton) + .props() + .onClick({} as MouseEvent); + + wrapper.unmount(); + + // Wait for the setStatus promise to resolve + await tick(); + + expect(refresh).not.toHaveBeenCalled(); + }); + + it("resets processing flag if a setStatus request fails", async () => { + const setStatus = jest.fn(() => Promise.reject({ error: "test" })); + + const wrapper: EnableDisableWrapper = shallow( + + ); + + wrapper + .find(NavButton) + .props() + .onClick({} as MouseEvent); + + expect(wrapper.state().processing).toBeTruthy(); + + // Wait for the setStatus promise to resolve + await tick(); + + expect(wrapper.state().processing).toBeFalsy(); + }); + + it("calls the API with the custom time specified via modal, and closes the modal", () => { + const setStatus = jest.fn(() => Promise.resolve({ status: "success" })); + const event = { preventDefault: jest.fn() }; + + const wrapper: EnableDisableWrapper = shallow( + + ); + + wrapper.setState({ customModalShown: true }); + wrapper + .find(Form) + .props() + .onSubmit(event); + expect(wrapper.find(Modal).props().isOpen).toBeFalsy(); + expect(setStatus).toHaveBeenCalledWith("disable", 60 * 60); + }); + }); + + describe("interactions", () => { + it("opens the custom time modal with the corresponding button is clicked", () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(Modal).props().isOpen).toBeFalsy(); + + wrapper + .find(NavButton) + .last() + .props() + .onClick({} as MouseEvent); + + expect(wrapper.find(Modal).props().isOpen).toBeTruthy(); + }); + + it("should close the modal when clicking outside", () => { + const wrapper: EnableDisableWrapper = shallow( + + ); + + wrapper.setState({ customModalShown: true }); + expect(wrapper.find(Modal).props().isOpen).toBeTruthy(); + wrapper + .find(Modal) + .props() + .toggle(); + expect(wrapper.find(Modal).props().isOpen).toBeFalsy(); + }); + + it("should close the modal when the header close button is clicked", () => { + const wrapper: EnableDisableWrapper = shallow( + + ); + + wrapper.setState({ customModalShown: true }); + expect(wrapper.find(Modal).props().isOpen).toBeTruthy(); + wrapper + .find(ModalHeader) + .props() + .toggle(); + expect(wrapper.find(Modal).props().isOpen).toBeFalsy(); + }); + + it("should close the modal when the cancel button is clicked", () => { + const wrapper: EnableDisableWrapper = shallow( + + ); + + wrapper.setState({ customModalShown: true }); + expect(wrapper.find(Modal).props().isOpen).toBeTruthy(); + wrapper + .find(ModalFooter) + .childAt(1) + .props() + .onClick(); + expect(wrapper.find(Modal).props().isOpen).toBeFalsy(); + }); + + it("should allow custom disable times", () => { + const wrapper: EnableDisableWrapper = shallow( + + ); + + wrapper.setState({ customModalShown: true }); + + // Check the default + let timeInput = wrapper.find(Input).first(); + expect(timeInput).toHaveValue(60); + + timeInput.props().onChange({ target: { value: "42" } }); + timeInput = wrapper.find(Input).first(); + expect(timeInput).toHaveValue(42); + }); + + it("should allow changing the custom disable time unit", () => { + const wrapper: EnableDisableWrapper = shallow( + + ); + + wrapper.setState({ customModalShown: true }); + + // Check the default + let timeUnit = wrapper.find(Input).last(); + expect(timeUnit).toHaveValue(60); + + timeUnit.props().onChange({ target: { value: "1" } }); + timeUnit = wrapper.find(Input).last(); + expect(timeUnit).toHaveValue(1); + }); + }); + + describe("rendering", () => { + it("renders null if status is unknown", () => { + const wrapper = shallow( + + ); + + expect(wrapper).toBeEmptyRender(); + }); + + it("shows an enable button if status is disabled", () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(NavButton).props().name).toEqual("Enable"); + }); + + it("shows a dropdown with disable buttons if status is enabled", () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(NavDropdown)).toExist(); + expect(wrapper.find(NavButton)).toHaveLength(5); + }); + }); +}); diff --git a/src/routes.tsx b/src/routes.tsx index 07e8e0f..7999fd7 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -17,7 +17,7 @@ import Versions from "./views/Versions"; import Networking from "./views/Networking"; import Login from "./views/Login"; import Logout from "./views/Logout"; -import EnableDisable from "./components/common/EnableDisable"; +import { EnableDisableContainer } from "./components/common/EnableDisable"; import Preferences from "./views/Preferences"; import i18next from "i18next"; import { ComponentType } from "react"; @@ -109,7 +109,7 @@ export const nav: Array = [ ] }, { - customComponent: EnableDisable, + customComponent: EnableDisableContainer, fakeRoute: true, auth: true },