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,
+}