refactor(frontend): fetch to axios #22

Merged
mcataford merged 2 commits from refactor/fetch-to-axios into main 2023-08-18 05:24:13 +00:00
11 changed files with 154 additions and 122 deletions

View file

@ -18,7 +18,7 @@ tasks:
test:
desc: "Runs the frontend test suite."
deps: [bootstrap]
cmd: yarn test
cmd: yarn test {{ .CLI_ARGS }}
dir: frontend
lint:
desc: "Checks lint and formatting."

View file

@ -2,6 +2,7 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
setupFilesAfterEnv: ["./tests/testSetup.ts"],
transform: {
"^.+\\.(ts|tsx)$": "ts-jest",
},

View file

@ -6,6 +6,7 @@
"@mui/icons-material": "^5.14.1",
"@mui/material": "^5.14.2",
"@tanstack/react-query": "^4.32.6",
"axios": "^1.4.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
@ -26,6 +27,8 @@
"@types/jest": "^29.5.3",
"@types/react": "^18.2.18",
"@types/react-dom": "^18.2.7",
"axios-mock-adapter": "^1.21.5",
"buffer": "^5.5.0||^6.0.0",
"jest": "^29.6.2",
"jest-environment-jsdom": "^29.6.2",
"parcel": "^2.9.3",

View file

@ -1,6 +1,10 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"
import makeRequest from "./requestUtils"
import axios from "axios"
export const axiosWithDefaults = axios.create({
baseURL: "http://localhost:8000",
})
interface FileData {
/* Displayed title of the item. */
@ -15,21 +19,17 @@ interface FileData {
function useOwnFileList() {
return useQuery(["file-list"], async () => {
const response = await makeRequest<Array<FileData>>(
"http://localhost:8000/files/",
)
const response = await axiosWithDefaults.get<Array<FileData>>("/files/")
return response.json
return response.data
})
}
function useFileDetails(fileId: string) {
return useQuery(["file-details", fileId], async () => {
const response = await makeRequest<FileData>(
`http://localhost:8000/files/${fileId}/`,
)
const response = await axiosWithDefaults.get<FileData>(`/files/${fileId}/`)
return response.json
return response.data
})
}
@ -43,13 +43,12 @@ function useFileMutations(): {
const queryClient = useQueryClient()
const deleteFile = async (fileId: string): Promise<FileData> => {
const response = await makeRequest<FileData>(
`http://localhost:8000/files/${fileId}`,
{ method: "DELETE" },
const response = await axiosWithDefaults.delete<FileData>(
`/files/${fileId}/`,
)
queryClient.invalidateQueries({ queryKey: ["file-list"] })
return response.json
return response.data
}
return { deleteFile }
@ -62,12 +61,12 @@ async function uploadFile(file: File) {
const formData = new FormData()
formData.append("file", file)
const response = await makeRequest<FileData>("http://localhost:8000/files/", {
method: "POST",
body: formData,
})
const response = await axiosWithDefaults.postForm<FileData>(
"/files/",
formData,
)
return response.json
return response.data
}
export { useOwnFileList, useFileDetails, useFileMutations, uploadFile }

View file

@ -1,45 +0,0 @@
/*
* Request utilities.
*
* To avoid relying too much on the `fetch` global directly and to facilitate
* testing, this utility abstracts the logic that makes network requests.
*/
interface RequestOptions {
method: string
body?: FormData | string
}
interface Response<ResponseSchema> {
status: number
json: ResponseSchema
}
type MakeRequestFn<Schema> = (
url: string,
opts?: RequestOptions,
) => Promise<Response<Schema>>
/*
* Wrapper for the logic that makes network requests.
*
* This is primarily done to make testing a bit easier, but also to centralize
* shared concerns across requests (i.e. common headers and such).
*/
async function makeRequest<ResponseSchema>(
url: string,
options?: RequestOptions,
): Promise<Response<ResponseSchema>> {
const response = await fetch(url, { ...(options ?? {}) })
const json = await response.json()
return {
status: response.status,
json: json,
}
}
export { RequestOptions, Response, MakeRequestFn }
export default makeRequest

View file

@ -3,9 +3,8 @@ import { within } from "@testing-library/dom"
import userEvent from "@testing-library/user-event"
import { type UseQueryResult } from "@tanstack/react-query"
import { renderWithContexts as render, applyMakeRequestMock } from "./helpers"
import { renderWithContexts as render, getAxiosMockAdapter } from "./helpers"
import FileDetails from "../src/components/FileDetails"
import * as requestUtil from "../src/queries/requestUtils"
import { type FileData } from "../src/queries/files"
import * as fileQueries from "../src/queries/files"
import * as locationContextUtils from "../src/contexts/LocationContext"
@ -18,12 +17,11 @@ describe("FileDetails", () => {
id: "b61bf93d-a9db-473e-822e-a65003b1b7e3",
}
test("Clicking the delete button fires request to delete file", async () => {
const spy = applyMakeRequestMock<FileData>(
async (url: string, opts?: requestUtil.RequestOptions) => ({
status: 200,
json: mockItem,
}),
)
const expectedUrlPattern = new RegExp(`/files/${mockItem.id}/$`)
const axiosMock = getAxiosMockAdapter()
axiosMock.onDelete(expectedUrlPattern).reply(200, mockItem)
jest
.spyOn(fileQueries, "useFileDetails")
@ -41,21 +39,21 @@ describe("FileDetails", () => {
await user.click(deleteButton)
expect(spy.mock.calls.length).toEqual(1)
const deleteRequests = axiosMock.history.delete
const deleteRequest = spy.mock.calls[0]
expect(deleteRequests.length).toEqual(1)
expect(deleteRequest[0]).toMatch(new RegExp(`\/files\/${mockItem.id}\$`))
expect(deleteRequest[1]?.method).toEqual("DELETE")
const deleteRequest = deleteRequests[0]
expect(deleteRequest.url).toMatch(expectedUrlPattern)
})
test("Clicking the delete button redirects to the file list after success", async () => {
const spy = applyMakeRequestMock<FileData>(
async (url: string, opts?: requestUtil.RequestOptions) => ({
status: 200,
json: mockItem,
}),
)
const expectedUrlPattern = new RegExp(`/files/${mockItem.id}/$`)
const axiosMock = getAxiosMockAdapter()
axiosMock.onDelete(expectedUrlPattern).reply(200, mockItem)
jest
.spyOn(fileQueries, "useFileDetails")

View file

@ -1,10 +1,9 @@
import { within } from "@testing-library/dom"
import userEvent from "@testing-library/user-event"
import { renderWithContexts as render, applyMakeRequestMock } from "./helpers"
import { renderWithContexts as render, getAxiosMockAdapter } from "./helpers"
import FileList from "../src/components/FileList"
import * as requestUtil from "../src/queries/requestUtils"
import { type FileData } from "../src/queries/files"
import AxiosMockAdapter from "axios-mock-adapter"
describe("FileList", () => {
const mockItems = [
@ -75,12 +74,12 @@ describe("FileList", () => {
)
test("Clicking the delete button fires request to delete file", async () => {
const spy = applyMakeRequestMock<FileData>(
async (url: string, opts?: requestUtil.RequestOptions) => ({
status: 200,
json: mockItems[0],
}),
)
const expectedUrlPattern = new RegExp(`/files/${mockItems[0].id}/$`)
const axiosMock = getAxiosMockAdapter()
axiosMock.onDelete(expectedUrlPattern).reply(200, mockItems[0])
const user = userEvent.setup()
const { getByLabelText, debug } = render(
@ -90,14 +89,13 @@ describe("FileList", () => {
await user.click(deleteButton)
expect(spy.mock.calls.length).toEqual(1)
const deleteRequests = axiosMock.history.delete
const deleteRequest = spy.mock.calls[0]
expect(deleteRequests.length).toEqual(1)
expect(deleteRequest[0]).toMatch(
new RegExp(`\/files\/${mockItems[0].id}\$`),
)
expect(deleteRequest[1]?.method).toEqual("DELETE")
const deleteRequest = deleteRequests[0]
expect(deleteRequest.url).toMatch(expectedUrlPattern)
})
})
})

View file

@ -1,9 +1,9 @@
import { within } from "@testing-library/dom"
import userEvent from "@testing-library/user-event"
import { renderWithContexts as render, applyMakeRequestMock } from "./helpers"
import { renderWithContexts as render, getAxiosMockAdapter } from "./helpers"
import NavigationBar from "../src/components/NavigationBar"
import * as requestUtil from "../src/queries/requestUtils"
import { type FileData } from "../src/queries/files"
describe("NavigationBar", () => {
@ -14,17 +14,14 @@ describe("NavigationBar", () => {
})
test("Clicking the upload button and selecting a file POSTs the file", async () => {
const spy = applyMakeRequestMock<FileData>(
async (url: string, opts?: requestUtil.RequestOptions) => ({
status: 200,
json: {
id: "b61bf93d-a9db-473e-822e-a65003b1b7e3",
filename: "test.txt",
title: "test",
size: 1,
},
}),
)
const axiosMock = getAxiosMockAdapter()
const expectedUrlPattern = new RegExp("/files/$")
axiosMock.onPost(expectedUrlPattern).reply(200, {
id: "b61bf93d-a9db-473e-822e-a65003b1b7e3",
filename: "test.txt",
title: "test",
size: 1,
})
const user = userEvent.setup()
@ -38,13 +35,13 @@ describe("NavigationBar", () => {
await user.upload(fileInput as HTMLInputElement, mockFile)
expect(spy.mock.calls.length).toEqual(1)
const call = spy.mock.lastCall
const postRequests = axiosMock.history.post
if (!call || call.length !== 2) fail("No last call or wrong length.")
expect(postRequests.length).toEqual(1)
expect(call[0]).toEqual("http://localhost:8000/files/")
expect(call[1]?.method).toEqual("POST")
const postRequest = postRequests[0]
expect(postRequest.url).toMatch(expectedUrlPattern)
})
})
})

View file

@ -1,13 +1,13 @@
import { type ReactNode } from "react"
import { render } from "@testing-library/react"
import { QueryClientProvider, QueryClient } from "@tanstack/react-query"
import { axiosWithDefaults } from "../src/queries/files"
import AxiosMockAdapter from "axios-mock-adapter"
import AsyncTaskContext, {
type AsyncTask,
} from "../src/contexts/AsyncTaskContext"
import LocationContext from "../src/contexts/LocationContext"
import * as requestUtil from "../src/queries/requestUtils"
import { type FileData } from "../src/queries/files"
interface ContextInitialValues {
asyncTaskContext: Array<AsyncTask>
@ -19,7 +19,7 @@ const defaultContextValues = {
locationContext: { default: "/" },
}
export function renderWithContexts(
function renderWithContexts(
component: ReactNode,
initialValues?: Partial<ContextInitialValues>,
) {
@ -35,8 +35,8 @@ export function renderWithContexts(
)
}
export function applyMakeRequestMock<Schema>(
impl: typeof requestUtil.default<Schema>,
) {
return jest.spyOn(requestUtil, "default").mockImplementation(impl)
function getAxiosMockAdapter() {
return new AxiosMockAdapter(axiosWithDefaults)
}
export { getAxiosMockAdapter, renderWithContexts }

View file

View file

@ -2666,6 +2666,29 @@ __metadata:
languageName: node
linkType: hard
"axios-mock-adapter@npm:^1.21.5":
version: 1.21.5
resolution: "axios-mock-adapter@npm:1.21.5"
dependencies:
fast-deep-equal: ^3.1.3
is-buffer: ^2.0.5
peerDependencies:
axios: ">= 0.17.0"
checksum: e3c2ccf220a2ddd316ccdff36b65754ddaf9b7a8dc70a64e9ff94da335a0694aaba3d925e6d7d5f71dd4badfe9af581bd7e6dd2dc09414d9900298afe12a71a0
languageName: node
linkType: hard
"axios@npm:^1.4.0":
version: 1.4.0
resolution: "axios@npm:1.4.0"
dependencies:
follow-redirects: ^1.15.0
form-data: ^4.0.0
proxy-from-env: ^1.1.0
checksum: 7fb6a4313bae7f45e89d62c70a800913c303df653f19eafec88e56cea2e3821066b8409bc68be1930ecca80e861c52aa787659df0ffec6ad4d451c7816b9386b
languageName: node
linkType: hard
"babel-jest@npm:^29.6.2":
version: 29.6.2
resolution: "babel-jest@npm:29.6.2"
@ -2769,6 +2792,13 @@ __metadata:
languageName: node
linkType: hard
"base64-js@npm:^1.3.1":
version: 1.5.1
resolution: "base64-js@npm:1.5.1"
checksum: 669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005
languageName: node
linkType: hard
"boolbase@npm:^1.0.0":
version: 1.0.0
resolution: "boolbase@npm:1.0.0"
@ -2843,6 +2873,16 @@ __metadata:
languageName: node
linkType: hard
"buffer@npm:^5.5.0||^6.0.0":
version: 6.0.3
resolution: "buffer@npm:6.0.3"
dependencies:
base64-js: ^1.3.1
ieee754: ^1.2.1
checksum: 5ad23293d9a731e4318e420025800b42bf0d264004c0286c8cc010af7a270c7a0f6522e84f54b9ad65cbd6db20b8badbfd8d2ebf4f80fa03dab093b89e68c3f9
languageName: node
linkType: hard
"cacache@npm:^17.0.0":
version: 17.1.3
resolution: "cacache@npm:17.1.3"
@ -3620,6 +3660,13 @@ __metadata:
languageName: node
linkType: hard
"fast-deep-equal@npm:^3.1.3":
version: 3.1.3
resolution: "fast-deep-equal@npm:3.1.3"
checksum: e21a9d8d84f53493b6aa15efc9cfd53dd5b714a1f23f67fb5dc8f574af80df889b3bce25dc081887c6d25457cce704e636395333abad896ccdec03abaf1f3f9d
languageName: node
linkType: hard
"fast-json-stable-stringify@npm:2.x, fast-json-stable-stringify@npm:^2.1.0":
version: 2.1.0
resolution: "fast-json-stable-stringify@npm:2.1.0"
@ -3662,6 +3709,16 @@ __metadata:
languageName: node
linkType: hard
"follow-redirects@npm:^1.15.0":
version: 1.15.2
resolution: "follow-redirects@npm:1.15.2"
peerDependenciesMeta:
debug:
optional: true
checksum: faa66059b66358ba65c234c2f2a37fcec029dc22775f35d9ad6abac56003268baf41e55f9ee645957b32c7d9f62baf1f0b906e68267276f54ec4b4c597c2b190
languageName: node
linkType: hard
"for-each@npm:^0.3.3":
version: 0.3.3
resolution: "for-each@npm:0.3.3"
@ -4070,6 +4127,13 @@ __metadata:
languageName: node
linkType: hard
"ieee754@npm:^1.2.1":
version: 1.2.1
resolution: "ieee754@npm:1.2.1"
checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e
languageName: node
linkType: hard
"import-fresh@npm:^3.2.1":
version: 3.3.0
resolution: "import-fresh@npm:3.3.0"
@ -4188,6 +4252,13 @@ __metadata:
languageName: node
linkType: hard
"is-buffer@npm:^2.0.5":
version: 2.0.5
resolution: "is-buffer@npm:2.0.5"
checksum: 764c9ad8b523a9f5a32af29bdf772b08eb48c04d2ad0a7240916ac2688c983bf5f8504bf25b35e66240edeb9d9085461f9b5dae1f3d2861c6b06a65fe983de42
languageName: node
linkType: hard
"is-callable@npm:^1.1.3":
version: 1.2.7
resolution: "is-callable@npm:1.2.7"
@ -5981,6 +6052,13 @@ __metadata:
languageName: node
linkType: hard
"proxy-from-env@npm:^1.1.0":
version: 1.1.0
resolution: "proxy-from-env@npm:1.1.0"
checksum: ed7fcc2ba0a33404958e34d95d18638249a68c430e30fcb6c478497d72739ba64ce9810a24f53a7d921d0c065e5b78e3822759800698167256b04659366ca4d4
languageName: node
linkType: hard
"psl@npm:^1.1.33":
version: 1.9.0
resolution: "psl@npm:1.9.0"
@ -6243,6 +6321,9 @@ __metadata:
"@types/jest": ^29.5.3
"@types/react": ^18.2.18
"@types/react-dom": ^18.2.7
axios: ^1.4.0
axios-mock-adapter: ^1.21.5
buffer: ^5.5.0||^6.0.0
jest: ^29.6.2
jest-environment-jsdom: ^29.6.2
parcel: ^2.9.3