Skip to content

Commit

Permalink
♻️ Refactor API Calls
Browse files Browse the repository at this point in the history
  • Loading branch information
0x46616c6b committed Feb 3, 2025
1 parent c3cb294 commit 08cf0f0
Show file tree
Hide file tree
Showing 37 changed files with 678 additions and 768 deletions.
38 changes: 37 additions & 1 deletion src/api/Api.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { apiClient, apiHeaders } from './Api'
import { apiClient, apiHeaders, ApiResponse, handleApiCall } from './Api'

describe('apiClient', () => {
beforeEach(() => {
Expand Down Expand Up @@ -31,3 +31,39 @@ describe('apiClient', () => {
expect(fetchMock).toHaveBeenCalledWith('/admin/features', { headers: apiHeaders('token') })
})
})

describe('handleApiCall', async () => {
const onSuccess = vi.fn()
const onError = vi.fn()
const onFailure = vi.fn()

beforeEach(() => {
onSuccess.mockClear()
onError.mockClear()
onFailure.mockClear()
})

it('should call onSuccess on success', async () => {
const apiCall = Promise.resolve({ status: 'success' } as ApiResponse<unknown>)
await handleApiCall(apiCall, { onSuccess, onError, onFailure })
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onError).not.toHaveBeenCalled()
expect(onFailure).not.toHaveBeenCalled()
})

it('should call onError on error', async () => {
const apiCall = Promise.resolve({ status: 'error' } as ApiResponse<unknown>)
await handleApiCall(apiCall, { onSuccess, onError, onFailure })
expect(onSuccess).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledTimes(1)
expect(onFailure).not.toHaveBeenCalled()
})

it('should call onFailure on failure', async () => {
const apiCall = Promise.reject(new Error('Network error'))
await handleApiCall(apiCall, { onSuccess, onError, onFailure })
expect(onSuccess).not.toHaveBeenCalled()
expect(onError).not.toHaveBeenCalled()
expect(onFailure).toHaveBeenCalledTimes(1)
})
})
21 changes: 21 additions & 0 deletions src/api/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,24 @@ export function apiHeaders(token: string): HeadersInit {
'Content-Type': 'application/json',
}
}

interface ApiCallParams<T> {
onSuccess: (response: ApiResponse<T>) => void
onError: (response: ApiResponse<T>) => void
onFailure: (error: unknown) => void
}

export async function handleApiCall<T>(apiCall: Promise<ApiResponse<T>>, params: ApiCallParams<T>): Promise<void> {
const { onSuccess, onError, onFailure } = params

try {
const response = await apiCall
if (response.status === 'error') {
onError(response)
} else {
onSuccess(response)
}
} catch (error) {
onFailure(error)
}
}
49 changes: 13 additions & 36 deletions src/components/ticker/BlueskyCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import sign from 'jwt-encode'
import { MemoryRouter } from 'react-router'
import { Ticker } from '../../api/Ticker'
import { AuthProvider } from '../../contexts/AuthContext'
import { NotificationProvider } from '../../contexts/NotificationContext'
import { queryClient, setup, userToken } from '../../tests/utils'
import BlueskyCard from './BlueskyCard'

const token = sign({ id: 1, email: '[email protected]', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret')

describe('BlueSkyCard', () => {
beforeAll(() => {
localStorage.setItem('token', token)
localStorage.setItem('token', userToken)
})

beforeEach(() => {
fetchMock.resetMocks()
})

const ticker = ({ active, connected, handle = '', appKey = '' }: { active: boolean; connected: boolean; handle?: string; appKey?: string }) => {
Expand All @@ -27,41 +25,20 @@ describe('BlueSkyCard', () => {
} as Ticker
}

beforeEach(() => {
fetchMock.resetMocks()
})

function setup(ticker: Ticker) {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<AuthProvider>
<NotificationProvider>
<BlueskyCard ticker={ticker} />
</NotificationProvider>
</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
)
const component = ({ ticker }: { ticker: Ticker }) => {
return <BlueskyCard ticker={ticker} />
}

it('should render the component', () => {
setup(ticker({ active: false, connected: false }))
setup(queryClient, component({ ticker: ticker({ active: false, connected: false }) }))

expect(screen.getByText('Bluesky')).toBeInTheDocument()
expect(screen.getByText('You are not connected with Bluesky.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument()
})

it('should render the component when connected and active', async () => {
setup(ticker({ active: true, connected: true, handle: 'handle.bsky.social' }))
setup(queryClient, component({ ticker: ticker({ active: true, connected: true, handle: 'handle.bsky.social' }) }))

expect(screen.getByText('Bluesky')).toBeInTheDocument()
expect(screen.getByText('You are connected with Bluesky.')).toBeInTheDocument()
Expand All @@ -80,7 +57,7 @@ describe('BlueSkyCard', () => {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token,
Authorization: 'Bearer ' + userToken,
},
method: 'put',
})
Expand All @@ -94,7 +71,7 @@ describe('BlueSkyCard', () => {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token,
Authorization: 'Bearer ' + userToken,
},
method: 'delete',
})
Expand Down
39 changes: 30 additions & 9 deletions src/components/ticker/BlueskyCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,55 @@ import { faGear, faPause, faPlay, faTrash } from '@fortawesome/free-solid-svg-ic
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Box, Button, Card, CardActions, CardContent, Divider, Link, Stack, Typography } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import { FC, useCallback, useState } from 'react'
import { FC, useState } from 'react'
import { handleApiCall } from '../../api/Api'
import { Ticker, deleteTickerBlueskyApi, putTickerBlueskyApi } from '../../api/Ticker'
import useAuth from '../../contexts/useAuth'
import useNotification from '../../contexts/useNotification'
import BlueskyModalForm from './BlueskyModalForm'

interface Props {
ticker: Ticker
}

const BlueskyCard: FC<Props> = ({ ticker }) => {
const { createNotification } = useNotification()
const { token } = useAuth()
const [open, setOpen] = useState<boolean>(false)

const queryClient = useQueryClient()

const bluesky = ticker.bluesky

const handleDelete = useCallback(() => {
deleteTickerBlueskyApi(token, ticker).finally(() => {
queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] })
const handleDelete = () => {
handleApiCall(deleteTickerBlueskyApi(token, ticker), {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] })
createNotification({ content: 'Bluesky integration successfully deleted', severity: 'success' })
},
onError: () => {
createNotification({ content: 'Failed to delete Bluesky integration', severity: 'error' })
},
onFailure: error => {
createNotification({ content: error as string, severity: 'error' })
},
})
}, [token, queryClient, ticker])
}

const handleToggle = useCallback(() => {
putTickerBlueskyApi(token, { active: !bluesky.active }, ticker).finally(() => {
queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] })
const handleToggle = () => {
handleApiCall(putTickerBlueskyApi(token, { active: !bluesky.active }, ticker), {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] })
createNotification({ content: `Bluesky integration ${bluesky.active ? 'disabled' : 'enabled'} successfully`, severity: 'success' })
},
onError: () => {
createNotification({ content: 'Failed to update Bluesky integration', severity: 'error' })
},
onFailure: error => {
createNotification({ content: error as string, severity: 'error' })
},
})
}, [bluesky.active, token, queryClient, ticker])
}

const profileLink = (
<Link href={'https://bsky.app/profile/' + bluesky.handle} rel="noreferrer" target="_blank">
Expand Down
49 changes: 15 additions & 34 deletions src/components/ticker/BlueskyForm.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
import sign from 'jwt-encode'
import { MemoryRouter } from 'react-router'
import { Ticker } from '../../api/Ticker'
import { AuthProvider } from '../../contexts/AuthContext'
import { NotificationProvider } from '../../contexts/NotificationContext'
import { queryClient, setup, userToken } from '../../tests/utils'
import BlueskyForm from './BlueskyForm'

const token = sign({ id: 1, email: '[email protected]', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret')

describe('BlueskyForm', () => {
beforeAll(() => {
localStorage.setItem('token', token)
localStorage.setItem('token', userToken)
})

beforeEach(() => {
fetchMock.resetMocks()
})

const ticker = ({ active, connected, handle = '', appKey = '' }: { active: boolean; connected: boolean; handle?: string; appKey?: string }) => {
Expand All @@ -29,34 +27,17 @@ describe('BlueskyForm', () => {

const callback = vi.fn()

beforeEach(() => {
fetchMock.resetMocks()
})

function setup(ticker: Ticker) {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<AuthProvider>
<NotificationProvider>
<BlueskyForm callback={callback} ticker={ticker} />
<input name="Submit" type="submit" value="Submit" form="configureBluesky" />
</NotificationProvider>
</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
const component = ({ ticker }: { ticker: Ticker }) => {
return (
<>
<BlueskyForm callback={callback} ticker={ticker} />
<input name="Submit" type="submit" value="Submit" form="configureBluesky" />
</>
)
}

it('should render the component', async () => {
setup(ticker({ active: false, connected: false }))
setup(queryClient, component({ ticker: ticker({ active: false, connected: false }) }))

expect(screen.getByText('You need to create a application password in Bluesky.')).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'Active' })).toBeInTheDocument()
Expand All @@ -78,7 +59,7 @@ describe('BlueskyForm', () => {
body: '{"active":true,"handle":"handle.bsky.social","appKey":"password"}',
headers: {
Accept: 'application/json',
Authorization: 'Bearer ' + token,
Authorization: 'Bearer ' + userToken,
'Content-Type': 'application/json',
},
method: 'put',
Expand Down
17 changes: 11 additions & 6 deletions src/components/ticker/BlueskyForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Grid from '@mui/material/Grid2'
import { useQueryClient } from '@tanstack/react-query'
import { FC } from 'react'
import { useForm } from 'react-hook-form'
import { handleApiCall } from '../../api/Api'
import { Ticker, TickerBlueskyFormData, putTickerBlueskyApi } from '../../api/Ticker'
import useAuth from '../../contexts/useAuth'
import useNotification from '../../contexts/useNotification'
Expand Down Expand Up @@ -31,15 +32,19 @@ const BlueskyForm: FC<Props> = ({ callback, ticker }) => {
const queryClient = useQueryClient()

const onSubmit = handleSubmit(data => {
putTickerBlueskyApi(token, data, ticker).then(response => {
if (response.status == 'error') {
setError('root.authenticationFailed', { message: 'Authentication failed' })
createNotification({ content: 'Bluesky integration failed to update', severity: 'error' })
} else {
handleApiCall(putTickerBlueskyApi(token, data, ticker), {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] })
createNotification({ content: 'Bluesky integration was successfully updated', severity: 'success' })
callback()
}
},
onError: () => {
setError('root.authenticationFailed', { message: 'Authentication failed' })
createNotification({ content: 'Failed to update Bluesky integration', severity: 'error' })
},
onFailure: error => {
createNotification({ content: error as string, severity: 'error' })
},
})
})

Expand Down
Loading

0 comments on commit 08cf0f0

Please sign in to comment.