refactor(frontend): fetch to axios (#22)
* refactor: replace fetch with axios * build(frontend): update test command to allow cli args passthrough
This commit is contained in:
parent
6965ce6ec4
commit
b77574e596
11 changed files with 154 additions and 122 deletions
|
@ -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."
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "jsdom",
|
||||
setupFilesAfterEnv: ["./tests/testSetup.ts"],
|
||||
transform: {
|
||||
"^.+\\.(ts|tsx)$": "ts-jest",
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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 }
|
||||
|
|
0
frontend/tests/testSetup.ts
Normal file
0
frontend/tests/testSetup.ts
Normal 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
|
||||
|
|
Reference in a new issue