diff --git a/ui/src/pages/providers/ProviderCreate/ProviderCreate.test.tsx b/ui/src/pages/providers/ProviderCreate/ProviderCreate.test.tsx new file mode 100644 index 000000000..3566337f8 --- /dev/null +++ b/ui/src/pages/providers/ProviderCreate/ProviderCreate.test.tsx @@ -0,0 +1,129 @@ +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 ProviderCreate from "./ProviderCreate"; +import { ProviderFormLabel } from "../ProviderForm"; +import { Label } from "./types"; +import { initialValues } from "./ProviderCreate"; +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("/idps").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.providers.index); +}); + +test("calls the API on submit", async () => { + const values = { + id: faker.word.sample(), + }; + renderComponent(); + const input = screen.getByRole("textbox", { name: ProviderFormLabel.NAME }); + await userEvent.click(input); + await userEvent.clear(input); + await userEvent.type(input, values.id); + await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT })); + expect(mock.history.post[0].url).toBe("/idps"); + expect(JSON.parse(mock.history.post[0].data as string)).toMatchObject({ + ...initialValues, + scope: initialValues.scope.split(","), + ...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("/idps").reply(200); + const values = { + id: faker.word.sample(), + }; + renderComponent( + + + + , + { + url: "/", + setLocation: (newLocation) => { + location = newLocation; + }, + }, + ); + await userEvent.type( + screen.getByRole("textbox", { name: ProviderFormLabel.NAME }), + values.id, + ); + await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT })); + await waitFor(() => + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: [queryKeys.providers], + }), + ); + expect(document.querySelector(".p-notification--positive")).toHaveTextContent( + Label.SUCCESS, + ), + expect((location as Location | null)?.pathname).toBe(urls.providers.index); +}); + +test("handles API failure", async () => { + mock.onPost("/idps").reply(400, { + message: "oops", + }); + const values = { + id: faker.word.sample(), + }; + renderComponent( + + + + , + ); + await userEvent.type( + screen.getByRole("textbox", { name: ProviderFormLabel.NAME }), + values.id, + ); + 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/providers/ProviderCreate/ProviderCreate.tsx b/ui/src/pages/providers/ProviderCreate/ProviderCreate.tsx index c3d83f8ee..06cc9773b 100644 --- a/ui/src/pages/providers/ProviderCreate/ProviderCreate.tsx +++ b/ui/src/pages/providers/ProviderCreate/ProviderCreate.tsx @@ -20,6 +20,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 = { + provider: "generic", + id: "", + client_id: "", + client_secret: "", + mapper_url: "", + scope: "email", + subject_source: "userinfo", +} as const; const ProviderCreate: FC = () => { const navigate = useNavigate(); @@ -31,15 +42,7 @@ const ProviderCreate: FC = () => { }); const formik = useFormik({ - initialValues: { - provider: "generic", - id: "", - client_id: "", - client_secret: "", - mapper_url: "", - scope: "email", - subject_source: "userinfo", - }, + initialValues, validationSchema: ProviderCreateSchema, onSubmit: (values) => { createProvider( @@ -49,12 +52,15 @@ const ProviderCreate: FC = () => { void queryClient.invalidateQueries({ queryKey: [queryKeys.providers], }); - const msg = `Provider created.`; - navigate("/provider", notify.queue(notify.success(msg))); + navigate("/provider", notify.queue(notify.success(Label.SUCCESS))); }) - .catch((e) => { + .catch((error: unknown) => { formik.setSubmitting(false); - notify.failure("Provider creation failed", e); + notify.failure( + Label.ERROR, + error instanceof Error ? error : null, + typeof error === "string" ? error : null, + ); }); }, }); @@ -85,7 +91,7 @@ const ProviderCreate: FC = () => { { disabled={!formik.isValid} onClick={submitForm} > - Save + {Label.SUBMIT} diff --git a/ui/src/pages/providers/ProviderCreate/types.ts b/ui/src/pages/providers/ProviderCreate/types.ts new file mode 100644 index 000000000..e1bb7fc68 --- /dev/null +++ b/ui/src/pages/providers/ProviderCreate/types.ts @@ -0,0 +1,6 @@ +export enum Label { + CANCEL = "Cancel", + ERROR = "Provider creation failed", + SUBMIT = "Save", + SUCCESS = "Provider created.", +} diff --git a/ui/src/pages/providers/ProviderEdit/ProviderEdit.test.tsx b/ui/src/pages/providers/ProviderEdit/ProviderEdit.test.tsx new file mode 100644 index 000000000..a602aa176 --- /dev/null +++ b/ui/src/pages/providers/ProviderEdit/ProviderEdit.test.tsx @@ -0,0 +1,166 @@ +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 { axiosInstance } from "api/axios"; + +import ProviderEdit from "./ProviderEdit"; +import { ProviderFormLabel } from "../ProviderForm"; +import { Label } from "./types"; +import { + NotificationProvider, + NotificationConsumer, +} from "@canonical/react-components"; +import { queryKeys } from "util/queryKeys"; +import { mockIdentityProvider } from "test/mocks/providers"; +import { IdentityProvider } from "types/provider"; + +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 provider: IdentityProvider; + +beforeEach(() => { + vi.spyOn(reactQuery, "useQueryClient").mockReturnValue({ + invalidateQueries: vi.fn(), + } as unknown as reactQuery.QueryClient); + mock.reset(); + provider = mockIdentityProvider({ + id: faker.word.sample(), + apple_private_key: faker.word.sample(), + apple_private_key_id: faker.word.sample(), + apple_team_id: faker.word.sample(), + auth_url: faker.word.sample(), + client_id: faker.word.sample(), + client_secret: faker.word.sample(), + issuer_url: faker.word.sample(), + mapper_url: faker.word.sample(), + microsoft_tenant: faker.word.sample(), + provider: faker.word.sample(), + requested_claims: faker.word.sample(), + subject_source: "userinfo", + token_url: faker.word.sample(), + scope: ["email"], + }); + mock.onGet(`/idps/${provider.id}`).reply(200, { data: [provider] }); + mock.onPatch(`/idps/${provider.id}`).reply(200); +}); + +test("can cancel", async () => { + let location: Location | null = null; + renderComponent(, { + url: `/?id=${provider.id}`, + setLocation: (newLocation) => { + location = newLocation; + }, + }); + await userEvent.click(screen.getByRole("button", { name: Label.CANCEL })); + expect((location as Location | null)?.pathname).toBe("/"); + expect((location as Location | null)?.search).toBe(""); +}); + +test("calls the API on submit", async () => { + const values = { + scope: faker.word.sample(), + }; + renderComponent(, { + url: `/?id=${provider.id}`, + }); + const input = screen.getByRole("textbox", { name: ProviderFormLabel.SCOPES }); + await userEvent.click(input); + await userEvent.clear(input); + await userEvent.type(input, values.scope); + await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT })); + expect(mock.history.patch[0].url).toBe(`/idps/${provider.id}`); + expect(JSON.parse(mock.history.patch[0].data as string)).toMatchObject({ + apple_private_key: provider.apple_private_key, + apple_private_key_id: provider.apple_private_key_id, + apple_team_id: provider.apple_team_id, + auth_url: provider.auth_url, + client_id: provider.client_id, + client_secret: provider.client_secret, + id: provider.id, + issuer_url: provider.issuer_url, + mapper_url: provider.mapper_url, + microsoft_tenant: provider.microsoft_tenant, + provider: provider.provider, + requested_claims: provider.requested_claims, + subject_source: "userinfo", + token_url: provider.token_url, + scope: [values.scope], + }); +}); + +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.onPatch(`/idps/${provider.id}`).reply(200); + const values = { + scope: faker.word.sample(), + }; + renderComponent( + + + + , + { + url: `/?id=${provider.id}`, + setLocation: (newLocation) => { + location = newLocation; + }, + }, + ); + await userEvent.type( + screen.getByRole("textbox", { name: ProviderFormLabel.SCOPES }), + values.scope, + ); + await userEvent.click(screen.getByRole("button", { name: Label.SUBMIT })); + await waitFor(() => + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: [queryKeys.providers], + }), + ); + expect(document.querySelector(".p-notification--positive")).toHaveTextContent( + Label.SUCCESS, + ); + expect((location as Location | null)?.pathname).toBe("/"); + expect((location as Location | null)?.search).toBe(""); +}); + +test("handles API failure", async () => { + mock.onPatch(`/idps/${provider.id}`).reply(400, { + message: "oops", + }); + const values = { + scope: faker.word.sample(), + }; + renderComponent( + + + + , + { url: `/?id=${provider.id}` }, + ); + await userEvent.type( + screen.getByRole("textbox", { name: ProviderFormLabel.SCOPES }), + values.scope, + ); + 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/providers/ProviderEdit/ProviderEdit.tsx b/ui/src/pages/providers/ProviderEdit/ProviderEdit.tsx index 196e19a69..7a74d7f96 100644 --- a/ui/src/pages/providers/ProviderEdit/ProviderEdit.tsx +++ b/ui/src/pages/providers/ProviderEdit/ProviderEdit.tsx @@ -17,6 +17,7 @@ import usePanelParams from "util/usePanelParams"; import ScrollableContainer from "components/ScrollableContainer"; import { TestId } from "./test-types"; import { testId } from "test/utils"; +import { Label } from "./types"; const ProviderEdit: FC = () => { const notify = useNotify(); @@ -24,13 +25,9 @@ const ProviderEdit: FC = () => { const panelParams = usePanelParams(); const providerId = panelParams.id; - if (!providerId) { - return; - } - const { data: provider } = useQuery({ queryKey: [queryKeys.providers, providerId], - queryFn: () => fetchProvider(providerId), + queryFn: () => (providerId ? fetchProvider(providerId) : null), }); const ProviderEditSchema = Yup.object().shape({ @@ -39,21 +36,21 @@ const ProviderEdit: FC = () => { const formik = useFormik({ initialValues: { - apple_private_key: provider?.apple_private_key, - apple_private_key_id: provider?.apple_private_key_id, - apple_team_id: provider?.apple_team_id, - auth_url: provider?.auth_url, - client_id: provider?.client_id, - client_secret: provider?.client_secret, - id: provider?.id, - issuer_url: provider?.issuer_url, - mapper_url: provider?.mapper_url, - microsoft_tenant: provider?.microsoft_tenant, - provider: provider?.provider, - requested_claims: provider?.requested_claims, - scope: provider?.scope?.join(","), - subject_source: provider?.subject_source, - token_url: provider?.token_url, + apple_private_key: provider?.apple_private_key || "", + apple_private_key_id: provider?.apple_private_key_id || "", + apple_team_id: provider?.apple_team_id || "", + auth_url: provider?.auth_url || "", + client_id: provider?.client_id || "", + client_secret: provider?.client_secret || "", + id: provider?.id || "", + issuer_url: provider?.issuer_url || "", + mapper_url: provider?.mapper_url || "", + microsoft_tenant: provider?.microsoft_tenant || "", + provider: provider?.provider || "", + requested_claims: provider?.requested_claims || "", + scope: provider?.scope?.join(",") || "", + subject_source: provider?.subject_source || "userinfo", + token_url: provider?.token_url || "", }, enableReinitialize: true, validationSchema: ProviderEditSchema, @@ -66,12 +63,16 @@ const ProviderEdit: FC = () => { void queryClient.invalidateQueries({ queryKey: [queryKeys.providers], }); - notify.success("Provider updated"); + notify.success(Label.SUCCESS); panelParams.clear(); }) - .catch((e) => { + .catch((error: unknown) => { formik.setSubmitting(false); - notify.failure("Provider update failed", e); + notify.failure( + Label.ERROR, + error instanceof Error ? error : null, + typeof error === "string" ? error : null, + ); }); }, }); @@ -102,7 +103,7 @@ const ProviderEdit: FC = () => { className="u-no-margin--bottom u-sv2" onClick={panelParams.clear} > - Cancel + {Label.CANCEL} { disabled={!formik.isValid} onClick={() => void formik.submitForm()} > - Update + {Label.SUBMIT} diff --git a/ui/src/pages/providers/ProviderEdit/types.ts b/ui/src/pages/providers/ProviderEdit/types.ts new file mode 100644 index 000000000..3a59e864f --- /dev/null +++ b/ui/src/pages/providers/ProviderEdit/types.ts @@ -0,0 +1,6 @@ +export enum Label { + CANCEL = "Cancel", + ERROR = "Provider update failed", + SUBMIT = "Update", + SUCCESS = "Provider updated", +} diff --git a/ui/src/pages/providers/ProviderForm/index.ts b/ui/src/pages/providers/ProviderForm/index.ts index 2fe31befb..2b5a42371 100644 --- a/ui/src/pages/providers/ProviderForm/index.ts +++ b/ui/src/pages/providers/ProviderForm/index.ts @@ -1 +1,2 @@ export { default, type ProviderFormTypes } from "./ProviderForm"; +export { Label as ProviderFormLabel } from "./types";