diff --git a/frontend/src/components/NavigationBar/NavigationBar.test.tsx b/frontend/src/components/NavigationBar/NavigationBar.test.tsx index 535086e..0ae2676 100644 --- a/frontend/src/components/NavigationBar/NavigationBar.test.tsx +++ b/frontend/src/components/NavigationBar/NavigationBar.test.tsx @@ -1,20 +1,32 @@ import { vi, it, describe, expect } from "vitest" import { within } from "@testing-library/dom" import userEvent from "@testing-library/user-event" +import { screen, render, waitFor } from "@testing-library/react" +import { QueryClientProvider, QueryClient } from "@tanstack/react-query" -import { - renderWithContexts as render, - getAxiosMockAdapter, -} from "../../tests/helpers" +import * as locationHook from "../../contexts/LocationContext" +import { getAxiosMockAdapter } from "../../tests/helpers" import NavigationBar from "." import { type FileData } from "../../types/files" +function renderComponent() { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ) + return { + ...render(, { wrapper }), + user: userEvent.setup(), + } +} + describe("NavigationBar", () => { describe("Upload functionality", () => { it("Renders the upload button", () => { - const { getByText } = render() - getByText("Upload file") + renderComponent() + expect(screen.queryByText("Upload file")).toBeInTheDocument() }) it("Clicking the upload button and selecting a file POSTs the file", async () => { @@ -27,10 +39,8 @@ describe("NavigationBar", () => { size: 1, }) - const user = userEvent.setup() - - const { getByText, container } = render() - const uploadButton = getByText("Upload file") + const { user, container } = renderComponent() + const uploadButton = screen.getByText("Upload file") const mockFile = new File(["test"], "test.txt", { type: "text/plain" }) const fileInput = container.querySelector('input[type="file"]') @@ -48,4 +58,40 @@ describe("NavigationBar", () => { expect(postRequest.url).toMatch(expectedUrlPattern) }) }) + + describe("Log out", () => { + it("renders a logout button", () => { + renderComponent() + + expect(screen.queryByText("Log out")).toBeInTheDocument() + + const button = screen.getByText("Log out") + }) + + it("sends a logout request and redirects to the login page when logging out", async () => { + const axiosMock = getAxiosMockAdapter() + axiosMock.onDelete("/auth/session/").reply(204) + const mockNavigate = vi.fn() + const mockLocationHook = vi + .spyOn(locationHook, "useLocationContext") + .mockImplementation(() => ({ + location: { + path: "", + label: "", + params: {}, + pattern: "", + }, + navigate: mockNavigate, + })) + + const { user } = renderComponent() + + const logoutButton = screen.getByText("Log out") + + await user.click(logoutButton) + + await waitFor(() => expect(axiosMock.history.delete.length).toEqual(1)) + expect(mockNavigate).toHaveBeenCalledWith("/login") + }) + }) }) diff --git a/frontend/src/components/NavigationBar/index.tsx b/frontend/src/components/NavigationBar/index.tsx index e09b83f..5abb032 100644 --- a/frontend/src/components/NavigationBar/index.tsx +++ b/frontend/src/components/NavigationBar/index.tsx @@ -8,6 +8,7 @@ import Typography from "@mui/material/Typography" import UploadIcon from "@mui/icons-material/Upload" import { useFileMutations } from "../../hooks/files" +import { useLogout } from "../../queries/auth" function UploadFileButton() { const fileRef = useRef(null) @@ -45,6 +46,8 @@ function UploadFileButton() { } function NavigationBar() { + const { logout } = useLogout() + return ( @@ -52,6 +55,14 @@ function NavigationBar() { Rotini + ) diff --git a/frontend/src/queries/auth.test.tsx b/frontend/src/queries/auth.test.tsx new file mode 100644 index 0000000..083691b --- /dev/null +++ b/frontend/src/queries/auth.test.tsx @@ -0,0 +1,60 @@ +import React from "react" +import { describe, it, expect, vi } from "vitest" +import { renderHook, waitFor } from "@testing-library/react" +import { QueryClientProvider, QueryClient } from "@tanstack/react-query" +import AxiosMockAdapter from "axios-mock-adapter" + +import * as locationHook from "../contexts/LocationContext" +import axiosWithDefaults from "../axios" +import { useLogout } from "./auth" + +function WithProviders({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +describe("useLogout", () => { + it("sends a request to the logout api", async () => { + const axios = new AxiosMockAdapter(axiosWithDefaults) + + axios.onDelete("/auth/session/").reply(204) + + const { result } = renderHook(() => useLogout(), { wrapper: WithProviders }) + + result.current.logout() + + await waitFor(() => expect(axios.history.delete.length).toEqual(1)) + + const deleteRequest = axios.history.delete[0] + + expect(deleteRequest.url).toEqual("/auth/session/") + }) + + it("navigates to the login page on success", async () => { + const mockNavigate = vi.fn() + const mockLocationHook = vi + .spyOn(locationHook, "useLocationContext") + .mockImplementation(() => ({ + location: { + path: "", + label: "", + params: {}, + pattern: "", + }, + navigate: mockNavigate, + })) + + const axios = new AxiosMockAdapter(axiosWithDefaults) + + axios.onDelete("/auth/session/").reply(204) + + const { result } = renderHook(() => useLogout(), { wrapper: WithProviders }) + + result.current.logout() + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/login")) + }) +}) diff --git a/frontend/src/queries/auth.ts b/frontend/src/queries/auth.ts new file mode 100644 index 0000000..11fbe58 --- /dev/null +++ b/frontend/src/queries/auth.ts @@ -0,0 +1,43 @@ +/* + * Authentication queries + * + * This module contains the set of queries (both fetches and mutations) that + * affect the user's session. + * + */ +import { useMutation } from "@tanstack/react-query" +import { useLocationContext } from "../contexts/LocationContext" +import axiosWithDefaults from "../axios" + +/* + * Handles the log-out interaction. + * + * Using `logout` will instruct the application to invalidate + * the current authentication token and will redirect the user + * to the login page. + */ +function useLogout() { + const { navigate } = useLocationContext() + const logoutMutation = useMutation({ + mutationFn: async () => { + return axiosWithDefaults.delete("/auth/session/") + }, + onSuccess: () => { + navigate("/login") + }, + }) + + const { mutate, isError, isPending, ...rest } = logoutMutation + + return { + logout: mutate, + isError, + isPending, + } +} + +export { useLogout } + +export default { + useLogout, +}