From 63c30126ae50f0a8fa171f604417dd5352ad837a Mon Sep 17 00:00:00 2001 From: Huw Wilkins Date: Tue, 10 Dec 2024 15:53:55 +1100 Subject: [PATCH] chore(tests): add tests for client add/edit --- .../ClientCreate/ClientCreate.test.tsx | 132 ++++++++++++++++ .../clients/ClientCreate/ClientCreate.tsx | 33 ++-- ui/src/pages/clients/ClientCreate/types.ts | 5 + .../clients/ClientEdit/ClientEdit.test.tsx | 143 ++++++++++++++++++ .../pages/clients/ClientEdit/ClientEdit.tsx | 35 ++--- ui/src/pages/clients/ClientEdit/types.ts | 6 + ui/src/pages/clients/ClientForm/index.ts | 1 + 7 files changed, 325 insertions(+), 30 deletions(-) create mode 100644 ui/src/pages/clients/ClientCreate/ClientCreate.test.tsx create mode 100644 ui/src/pages/clients/ClientCreate/types.ts create mode 100644 ui/src/pages/clients/ClientEdit/ClientEdit.test.tsx create mode 100644 ui/src/pages/clients/ClientEdit/types.ts diff --git a/ui/src/pages/clients/ClientCreate/ClientCreate.test.tsx b/ui/src/pages/clients/ClientCreate/ClientCreate.test.tsx new file mode 100644 index 000000000..3fdc6b752 --- /dev/null +++ b/ui/src/pages/clients/ClientCreate/ClientCreate.test.tsx @@ -0,0 +1,132 @@ +import { screen, waitFor } from "@testing-library/dom"; +import { faker } from "@faker-js/faker"; +import userEvent from "@testing-library/user-event"; +import { Location } from "react-router-dom"; +import MockAdapter from "axios-mock-adapter"; +import * as reactQuery from "@tanstack/react-query"; + +import { renderComponent } from "test/utils"; +import { urls } from "urls"; +import { axiosInstance } from "api/axios"; + +import ClientCreate from "./ClientCreate"; +import { ClientFormLabel } from "../ClientForm"; +import { Label } from "./types"; +import { initialValues } from "./ClientCreate"; +import { + NotificationProvider, + NotificationConsumer, +} from "@canonical/react-components"; +import { queryKeys } from "util/queryKeys"; + +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQueryClient: vi.fn(), + }; +}); + +const mock = new MockAdapter(axiosInstance); + +beforeEach(() => { + mock.reset(); + vi.spyOn(reactQuery, "useQueryClient").mockReturnValue({ + invalidateQueries: vi.fn(), + } as unknown as reactQuery.QueryClient); + mock.onPost("/clients").reply(200); +}); + +test("can cancel", async () => { + let location: Location | null = null; + renderComponent(, { + url: "/", + setLocation: (newLocation) => { + location = newLocation; + }, + }); + await userEvent.click(screen.getByRole("button", { name: Label.CANCEL })); + expect((location as Location | null)?.pathname).toBe(urls.clients.index); +}); + +test("calls the API on submit", async () => { + const values = { + client_name: faker.word.sample(), + }; + renderComponent(); + const input = screen.getByRole("textbox", { name: ClientFormLabel.NAME }); + await userEvent.click(input); + await userEvent.clear(input); + await userEvent.type(input, values.client_name); + await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT })); + expect(mock.history.post[0].url).toBe("/clients"); + expect(mock.history.post[0].data).toBe( + JSON.stringify({ + ...initialValues, + ...values, + }), + ); +}); + +test("handles API success", async () => { + let location: Location | null = null; + const invalidateQueries = vi.fn(); + vi.spyOn(reactQuery, "useQueryClient").mockReturnValue({ + invalidateQueries, + } as unknown as reactQuery.QueryClient); + mock.onPost("/clients").reply(200, { + data: { client_id: "client1", client_secret: "secret1" }, + }); + const values = { + client_name: faker.word.sample(), + }; + renderComponent( + + + + , + { + url: "/", + setLocation: (newLocation) => { + location = newLocation; + }, + }, + ); + await userEvent.type( + screen.getByRole("textbox", { name: ClientFormLabel.NAME }), + values.client_name, + ); + await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT })); + await waitFor(() => + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: [queryKeys.clients], + }), + ); + expect(document.querySelector(".p-notification--positive")).toHaveTextContent( + "Client created. Id: client1 Secret: secret1", + ), + expect((location as Location | null)?.pathname).toBe(urls.clients.index); +}); + +test("handles API failure", async () => { + mock.onPost("/clients").reply(400, { + message: "oops", + }); + const values = { + client_name: faker.word.sample(), + }; + renderComponent( + + + + , + ); + await userEvent.type( + screen.getByRole("textbox", { name: ClientFormLabel.NAME }), + values.client_name, + ); + await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT })); + expect(document.querySelector(".p-notification--negative")).toHaveTextContent( + `${Label.ERROR}oops`, + ); +}); diff --git a/ui/src/pages/clients/ClientCreate/ClientCreate.tsx b/ui/src/pages/clients/ClientCreate/ClientCreate.tsx index 8aed12a08..eb1640fb4 100644 --- a/ui/src/pages/clients/ClientCreate/ClientCreate.tsx +++ b/ui/src/pages/clients/ClientCreate/ClientCreate.tsx @@ -17,6 +17,17 @@ import SidePanel from "components/SidePanel"; import ScrollableContainer from "components/ScrollableContainer"; import { TestId } from "./test-types"; import { testId } from "test/utils"; +import { Label } from "./types"; + +export const initialValues = { + client_uri: "", + client_name: "grafana", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code", "id_token"], + scope: "openid offline_access email", + redirect_uris: ["http://localhost:2345/login/generic_oauth"], + request_object_signing_alg: "RS256", +}; const ClientCreate: FC = () => { const navigate = useNavigate(); @@ -28,15 +39,7 @@ const ClientCreate: FC = () => { }); const formik = useFormik({ - initialValues: { - client_uri: "", - client_name: "grafana", - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code", "id_token"], - scope: "openid offline_access email", - redirect_uris: ["http://localhost:2345/login/generic_oauth"], - request_object_signing_alg: "RS256", - }, + initialValues, validationSchema: ClientCreateSchema, onSubmit: (values) => { createClient(JSON.stringify(values)) @@ -47,9 +50,13 @@ const ClientCreate: FC = () => { const msg = `Client created. Id: ${result.client_id} Secret: ${result.client_secret}`; navigate("/client", notify.queue(notify.success(msg))); }) - .catch((e) => { + .catch((error: unknown) => { formik.setSubmitting(false); - notify.failure("Client creation failed", e); + notify.failure( + Label.ERROR, + error instanceof Error ? error : null, + typeof error === "string" ? error : null, + ); }); }, }); @@ -80,7 +87,7 @@ const ClientCreate: FC = () => { { disabled={!formik.isValid} onClick={submitForm} > - Save + {Label.SUBMIT} diff --git a/ui/src/pages/clients/ClientCreate/types.ts b/ui/src/pages/clients/ClientCreate/types.ts new file mode 100644 index 000000000..c523ed4d5 --- /dev/null +++ b/ui/src/pages/clients/ClientCreate/types.ts @@ -0,0 +1,5 @@ +export enum Label { + CANCEL = "Cancel", + ERROR = "Client creation failed", + SUBMIT = "Save", +} diff --git a/ui/src/pages/clients/ClientEdit/ClientEdit.test.tsx b/ui/src/pages/clients/ClientEdit/ClientEdit.test.tsx new file mode 100644 index 000000000..5aa3048be --- /dev/null +++ b/ui/src/pages/clients/ClientEdit/ClientEdit.test.tsx @@ -0,0 +1,143 @@ +import { screen, waitFor } from "@testing-library/dom"; +import { faker } from "@faker-js/faker"; +import userEvent from "@testing-library/user-event"; +import { Location } from "react-router-dom"; +import MockAdapter from "axios-mock-adapter"; +import * as reactQuery from "@tanstack/react-query"; + +import { renderComponent } from "test/utils"; +import { urls } from "urls"; +import { axiosInstance } from "api/axios"; + +import ClientEdit from "./ClientEdit"; +import { ClientFormLabel } from "../ClientForm"; +import { Label } from "./types"; +import { + NotificationProvider, + NotificationConsumer, +} from "@canonical/react-components"; +import { queryKeys } from "util/queryKeys"; +import { mockClient } from "test/mocks/clients"; +import { Client } from "types/client"; + +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQueryClient: vi.fn(), + }; +}); + +const mock = new MockAdapter(axiosInstance); + +let client: Client; + +beforeEach(() => { + vi.spyOn(reactQuery, "useQueryClient").mockReturnValue({ + invalidateQueries: vi.fn(), + } as unknown as reactQuery.QueryClient); + mock.reset(); + client = mockClient(); + mock.onGet(`/clients/${client.client_id}`).reply(200, { data: client }); + mock.onPut(`/clients/${client.client_id}`).reply(200); +}); + +test("can cancel", async () => { + let location: Location | null = null; + renderComponent(, { + url: `/?id=${client.client_id}`, + setLocation: (newLocation) => { + location = newLocation; + }, + }); + await userEvent.click(screen.getByRole("button", { name: Label.CANCEL })); + expect((location as Location | null)?.pathname).toBe(urls.clients.index); +}); + +test("calls the API on submit", async () => { + const values = { + client_name: faker.word.sample(), + }; + renderComponent(, { + url: `/?id=${client.client_id}`, + }); + const input = screen.getByRole("textbox", { name: ClientFormLabel.NAME }); + await userEvent.click(input); + await userEvent.clear(input); + await userEvent.type(input, values.client_name); + await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT })); + expect(mock.history.put[0].url).toBe(`/clients/${client.client_id}`); + expect(JSON.parse(mock.history.put[0].data as string)).toMatchObject({ + client_uri: client.client_uri, + grant_types: client.grant_types, + response_types: client.response_types, + scope: client.scope, + redirect_uris: client.redirect_uris, + request_object_signing_alg: client.request_object_signing_alg, + ...values, + }); +}); + +test("handles API success", async () => { + let location: Location | null = null; + const invalidateQueries = vi.fn(); + vi.spyOn(reactQuery, "useQueryClient").mockReturnValue({ + invalidateQueries, + } as unknown as reactQuery.QueryClient); + mock.onPut(`/clients/${client.client_id}`).reply(200, { + data: { client_id: "client1", client_secret: "secret1" }, + }); + const values = { + client_name: faker.word.sample(), + }; + renderComponent( + + + + , + { + url: `/?id=${client.client_id}`, + setLocation: (newLocation) => { + location = newLocation; + }, + }, + ); + await userEvent.type( + screen.getByRole("textbox", { name: ClientFormLabel.NAME }), + values.client_name, + ); + await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT })); + await waitFor(() => + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: [queryKeys.clients], + }), + ); + expect(document.querySelector(".p-notification--positive")).toHaveTextContent( + Label.SUCCESS, + ), + expect((location as Location | null)?.pathname).toBe(urls.clients.index); +}); + +test("handles API failure", async () => { + mock.onPut(`/clients/${client.client_id}`).reply(400, { + message: "oops", + }); + const values = { + client_name: faker.word.sample(), + }; + renderComponent( + + + + , + { url: `/?id=${client.client_id}` }, + ); + await userEvent.type( + screen.getByRole("textbox", { name: ClientFormLabel.NAME }), + values.client_name, + ); + await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT })); + expect(document.querySelector(".p-notification--negative")).toHaveTextContent( + `${Label.ERROR}oops`, + ); +}); diff --git a/ui/src/pages/clients/ClientEdit/ClientEdit.tsx b/ui/src/pages/clients/ClientEdit/ClientEdit.tsx index d59e835f2..faa396e00 100644 --- a/ui/src/pages/clients/ClientEdit/ClientEdit.tsx +++ b/ui/src/pages/clients/ClientEdit/ClientEdit.tsx @@ -18,6 +18,7 @@ import SidePanel from "components/SidePanel"; import ScrollableContainer from "components/ScrollableContainer"; import { TestId } from "./test-types"; import { testId } from "test/utils"; +import { Label } from "./types"; const ClientEdit: FC = () => { const navigate = useNavigate(); @@ -26,13 +27,9 @@ const ClientEdit: FC = () => { const panelParams = usePanelParams(); const clientId = panelParams.id; - if (!clientId) { - return; - } - const { data } = useQuery({ queryKey: [queryKeys.clients, clientId], - queryFn: () => fetchClient(clientId), + queryFn: () => (clientId ? fetchClient(clientId) : null), }); const client = data?.data; @@ -42,13 +39,13 @@ const ClientEdit: FC = () => { const formik = useFormik({ initialValues: { - client_uri: client?.client_uri, - client_name: client?.client_name, - grant_types: client?.grant_types, - response_types: client?.response_types, - scope: client?.scope, - redirect_uris: client?.redirect_uris, - request_object_signing_alg: client?.request_object_signing_alg, + client_uri: client?.client_uri || "", + client_name: client?.client_name || "", + grant_types: client?.grant_types || [], + response_types: client?.response_types || [], + scope: client?.scope || "", + redirect_uris: client?.redirect_uris || [], + request_object_signing_alg: client?.request_object_signing_alg || "", }, enableReinitialize: true, validationSchema: ClientEditSchema, @@ -58,11 +55,15 @@ const ClientEdit: FC = () => { void queryClient.invalidateQueries({ queryKey: [queryKeys.clients], }); - navigate("/client", notify.queue(notify.success("Client updated"))); + navigate("/client", notify.queue(notify.success(Label.SUCCESS))); }) - .catch((e) => { + .catch((error: unknown) => { formik.setSubmitting(false); - notify.failure("Client update failed", e); + notify.failure( + Label.ERROR, + error instanceof Error ? error : null, + typeof error === "string" ? error : null, + ); }); }, }); @@ -97,7 +98,7 @@ const ClientEdit: FC = () => { className="u-no-margin--bottom u-sv2" onClick={() => navigate("/client")} > - Cancel + {Label.CANCEL} { disabled={!formik.isValid} onClick={submitForm} > - Update + {Label.SUBMIT} diff --git a/ui/src/pages/clients/ClientEdit/types.ts b/ui/src/pages/clients/ClientEdit/types.ts new file mode 100644 index 000000000..9687f6d6e --- /dev/null +++ b/ui/src/pages/clients/ClientEdit/types.ts @@ -0,0 +1,6 @@ +export enum Label { + CANCEL = "Cancel", + ERROR = "Client update failed", + SUBMIT = "Update", + SUCCESS = "Client updated", +} diff --git a/ui/src/pages/clients/ClientForm/index.ts b/ui/src/pages/clients/ClientForm/index.ts index 8d4991efb..ceb42a504 100644 --- a/ui/src/pages/clients/ClientForm/index.ts +++ b/ui/src/pages/clients/ClientForm/index.ts @@ -1,2 +1,3 @@ export { default } from "./ClientForm"; export type { ClientFormTypes } from "./ClientForm"; +export { Label as ClientFormLabel } from "./types";