diff --git a/src/api/Api.test.ts b/src/api/Api.test.ts index 9ab6d99..8063296 100644 --- a/src/api/Api.test.ts +++ b/src/api/Api.test.ts @@ -1,4 +1,4 @@ -import { apiClient, apiHeaders } from './Api' +import { apiClient, apiHeaders, ApiResponse, handleApiCall } from './Api' describe('apiClient', () => { beforeEach(() => { @@ -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) + 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) + 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) + }) +}) diff --git a/src/api/Api.ts b/src/api/Api.ts index 57d4734..a270844 100644 --- a/src/api/Api.ts +++ b/src/api/Api.ts @@ -47,3 +47,24 @@ export function apiHeaders(token: string): HeadersInit { 'Content-Type': 'application/json', } } + +interface ApiCallParams { + onSuccess: (response: ApiResponse) => void + onError: (response: ApiResponse) => void + onFailure: (error: unknown) => void +} + +export async function handleApiCall(apiCall: Promise>, params: ApiCallParams): Promise { + const { onSuccess, onError, onFailure } = params + + try { + const response = await apiCall + if (response.status === 'error') { + onError(response) + } else { + onSuccess(response) + } + } catch (error) { + onFailure(error) + } +} diff --git a/src/components/ticker/BlueskyCard.test.tsx b/src/components/ticker/BlueskyCard.test.tsx index 78f85f4..58c1248 100644 --- a/src/components/ticker/BlueskyCard.test.tsx +++ b/src/components/ticker/BlueskyCard.test.tsx @@ -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: 'user@example.org', 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 }) => { @@ -27,33 +25,12 @@ describe('BlueSkyCard', () => { } as Ticker } - beforeEach(() => { - fetchMock.resetMocks() - }) - - function setup(ticker: Ticker) { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return render( - - - - - - - - - - ) + const component = ({ ticker }: { ticker: Ticker }) => { + return } 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() @@ -61,7 +38,7 @@ describe('BlueSkyCard', () => { }) 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() @@ -80,7 +57,7 @@ describe('BlueSkyCard', () => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: 'Bearer ' + token, + Authorization: 'Bearer ' + userToken, }, method: 'put', }) @@ -94,7 +71,7 @@ describe('BlueSkyCard', () => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: 'Bearer ' + token, + Authorization: 'Bearer ' + userToken, }, method: 'delete', }) diff --git a/src/components/ticker/BlueskyCard.tsx b/src/components/ticker/BlueskyCard.tsx index eb2e937..cda435e 100644 --- a/src/components/ticker/BlueskyCard.tsx +++ b/src/components/ticker/BlueskyCard.tsx @@ -3,9 +3,11 @@ 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 { @@ -13,6 +15,7 @@ interface Props { } const BlueskyCard: FC = ({ ticker }) => { + const { createNotification } = useNotification() const { token } = useAuth() const [open, setOpen] = useState(false) @@ -20,17 +23,35 @@ const BlueskyCard: FC = ({ ticker }) => { 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 = ( diff --git a/src/components/ticker/BlueskyForm.test.tsx b/src/components/ticker/BlueskyForm.test.tsx index 3dcd52a..12565c7 100644 --- a/src/components/ticker/BlueskyForm.test.tsx +++ b/src/components/ticker/BlueskyForm.test.tsx @@ -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: 'user@example.org', 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 }) => { @@ -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( - - - - - - - - - - + const component = ({ ticker }: { ticker: Ticker }) => { + return ( + <> + + + ) } 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() @@ -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', diff --git a/src/components/ticker/BlueskyForm.tsx b/src/components/ticker/BlueskyForm.tsx index 59824a6..1ea31dd 100644 --- a/src/components/ticker/BlueskyForm.tsx +++ b/src/components/ticker/BlueskyForm.tsx @@ -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' @@ -31,15 +32,19 @@ const BlueskyForm: FC = ({ 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' }) + }, }) }) diff --git a/src/components/ticker/MastodonCard.test.tsx b/src/components/ticker/MastodonCard.test.tsx index 72d6b0d..c7ea151 100644 --- a/src/components/ticker/MastodonCard.test.tsx +++ b/src/components/ticker/MastodonCard.test.tsx @@ -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 MastodonCard from './MastodonCard' -const token = sign({ id: 1, email: 'user@example.org', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret') - describe('MastodonCard', () => { beforeAll(() => { - localStorage.setItem('token', token) + localStorage.setItem('token', userToken) + }) + + beforeEach(() => { + fetchMock.resetMocks() }) const ticker = ({ active, connected, name = '' }: { active: boolean; connected: boolean; name?: string }) => { @@ -27,33 +25,12 @@ describe('MastodonCard', () => { } as Ticker } - beforeEach(() => { - fetchMock.resetMocks() - }) - - function setup(ticker: Ticker) { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return render( - - - - - - - - - - ) + const component = ({ ticker }: { ticker: Ticker }) => { + return } it('should render the component', () => { - setup(ticker({ active: false, connected: false })) + setup(queryClient, component({ ticker: ticker({ active: false, connected: false }) })) expect(screen.getByText('Mastodon')).toBeInTheDocument() expect(screen.getByText('You are not connected with Mastodon.')).toBeInTheDocument() @@ -61,7 +38,7 @@ describe('MastodonCard', () => { }) it('should render the component when connected and active', async () => { - setup(ticker({ active: true, connected: true, name: 'user' })) + setup(queryClient, component({ ticker: ticker({ active: true, connected: true, name: 'user' }) })) expect(screen.getByText('Mastodon')).toBeInTheDocument() expect(screen.getByText('You are connected with Mastodon.')).toBeInTheDocument() @@ -81,7 +58,7 @@ describe('MastodonCard', () => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: 'Bearer ' + token, + Authorization: 'Bearer ' + userToken, }, method: 'put', }) @@ -95,7 +72,7 @@ describe('MastodonCard', () => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: 'Bearer ' + token, + Authorization: 'Bearer ' + userToken, }, method: 'delete', }) diff --git a/src/components/ticker/MastodonCard.tsx b/src/components/ticker/MastodonCard.tsx index d24c34d..e4548d7 100644 --- a/src/components/ticker/MastodonCard.tsx +++ b/src/components/ticker/MastodonCard.tsx @@ -3,9 +3,11 @@ 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, deleteTickerMastodonApi, putTickerMastodonApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' +import useNotification from '../../contexts/useNotification' import MastodonModalForm from './MastodonModalForm' interface Props { @@ -13,6 +15,7 @@ interface Props { } const MastodonCard: FC = ({ ticker }) => { + const { createNotification } = useNotification() const { token } = useAuth() const [open, setOpen] = useState(false) @@ -20,17 +23,35 @@ const MastodonCard: FC = ({ ticker }) => { const mastodon = ticker.mastodon - const handleDelete = useCallback(() => { - deleteTickerMastodonApi(token, ticker).finally(() => { - queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + const handleDelete = () => { + handleApiCall(deleteTickerMastodonApi(token, ticker), { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + createNotification({ content: 'Mastodon integration successfully deleted', severity: 'success' }) + }, + onError: () => { + createNotification({ content: 'Failed to delete Mastodon integration', severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, }) - }, [token, queryClient, ticker]) + } - const handleToggle = useCallback(() => { - putTickerMastodonApi(token, { active: !mastodon.active }, ticker).finally(() => { - queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + const handleToggle = () => { + handleApiCall(putTickerMastodonApi(token, { active: !mastodon.active }, ticker), { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + createNotification({ content: `Mastodon integration ${mastodon.active ? 'disabled' : 'enabled'} successfully`, severity: 'success' }) + }, + onError: () => { + createNotification({ content: 'Failed to update Mastodon integration', severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, }) - }, [mastodon.active, token, queryClient, ticker]) + } const profileLink = ( diff --git a/src/components/ticker/MastodonForm.test.tsx b/src/components/ticker/MastodonForm.test.tsx index 32ee97b..a0754f1 100644 --- a/src/components/ticker/MastodonForm.test.tsx +++ b/src/components/ticker/MastodonForm.test.tsx @@ -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 MastodonForm from './MastodonForm' -const token = sign({ id: 1, email: 'user@example.org', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret') - describe('MastodonForm', () => { beforeAll(() => { - localStorage.setItem('token', token) + localStorage.setItem('token', userToken) + }) + + beforeEach(() => { + fetchMock.resetMocks() }) const ticker = ({ active, connected, name = '' }: { active: boolean; connected: boolean; name?: string }) => { @@ -29,34 +27,17 @@ describe('MastodonForm', () => { const callback = vi.fn() - beforeEach(() => { - fetchMock.resetMocks() - }) - - function setup(ticker: Ticker) { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return render( - - - - - - - - - - + const component = ({ ticker }: { ticker: Ticker }) => { + return ( + <> + + + ) } it('should render the component', async () => { - setup(ticker({ active: false, connected: false })) + setup(queryClient, component({ ticker: ticker({ active: false, connected: false }) })) expect(screen.getByRole('checkbox', { name: 'Active' })).toBeInTheDocument() expect(screen.getByLabelText('Server *')).toBeInTheDocument() @@ -86,7 +67,7 @@ describe('MastodonForm', () => { }), headers: { Accept: 'application/json', - Authorization: 'Bearer ' + token, + Authorization: 'Bearer ' + userToken, 'Content-Type': 'application/json', }, method: 'put', diff --git a/src/components/ticker/MastodonForm.tsx b/src/components/ticker/MastodonForm.tsx index 538e85f..0ef6306 100644 --- a/src/components/ticker/MastodonForm.tsx +++ b/src/components/ticker/MastodonForm.tsx @@ -3,6 +3,7 @@ import Grid from '@mui/material/Grid2' import { useQueryClient } from '@tanstack/react-query' import { FC } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' +import { handleApiCall } from '../../api/Api' import { Ticker, TickerMastodonFormData, putTickerMastodonApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' @@ -25,10 +26,18 @@ const MastodonForm: FC = ({ callback, ticker }) => { const queryClient = useQueryClient() const onSubmit: SubmitHandler = data => { - putTickerMastodonApi(token, data, ticker).finally(() => { - queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: 'Mastodon integration was successfully updated', severity: 'success' }) - callback() + handleApiCall(putTickerMastodonApi(token, data, ticker), { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + createNotification({ content: 'Mastodon integration was successfully updated', severity: 'success' }) + callback() + }, + onError: () => { + createNotification({ content: 'Failed to update Mastodon integration', severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, }) } diff --git a/src/components/ticker/SignalGroupAdminForm.test.tsx b/src/components/ticker/SignalGroupAdminForm.test.tsx index b415981..487beaf 100644 --- a/src/components/ticker/SignalGroupAdminForm.test.tsx +++ b/src/components/ticker/SignalGroupAdminForm.test.tsx @@ -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 SignalGroupAdminForm from './SignalGroupAdminForm' -const token = sign({ id: 1, email: 'user@example.org', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret') - describe('SignalGroupAdminForm', () => { beforeAll(() => { - localStorage.setItem('token', token) + localStorage.setItem('token', userToken) + }) + + beforeEach(() => { + fetchMock.resetMocks() }) const ticker = ({ active, connected }: { active: boolean; connected: boolean }) => { @@ -28,36 +26,17 @@ describe('SignalGroupAdminForm', () => { const callback = vi.fn() const setSubmitting = vi.fn() - beforeEach(() => { - fetchMock.resetMocks() - }) - - function setup(ticker: Ticker) { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return render( - - - - -
- - -
-
-
-
-
+ const component = ({ ticker }: { ticker: Ticker }) => { + return ( + <> + + + ) } it('should render the component', async () => { - setup(ticker({ active: false, connected: false })) + setup(queryClient, component({ ticker: ticker({ active: false, connected: false }) })) expect(screen.getByText('Only do this if extra members with write access are needed.')).toBeInTheDocument() expect(screen.getByRole('textbox', { name: 'Phone number' })).toBeInTheDocument() @@ -75,7 +54,7 @@ describe('SignalGroupAdminForm', () => { body: '{"number":"+49123456789"}', headers: { Accept: 'application/json', - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${userToken}`, 'Content-Type': 'application/json', }, method: 'put', @@ -83,7 +62,7 @@ describe('SignalGroupAdminForm', () => { }) it('should render the error message', async () => { - setup(ticker({ active: false, connected: false })) + setup(queryClient, component({ ticker: ticker({ active: false, connected: false }) })) expect(screen.getByText('Only do this if extra members with write access are needed.')).toBeInTheDocument() expect(screen.getByRole('textbox', { name: 'Phone number' })).toBeInTheDocument() diff --git a/src/components/ticker/SignalGroupAdminForm.tsx b/src/components/ticker/SignalGroupAdminForm.tsx index d8f2fc3..f0db685 100644 --- a/src/components/ticker/SignalGroupAdminForm.tsx +++ b/src/components/ticker/SignalGroupAdminForm.tsx @@ -4,6 +4,7 @@ import { Alert, FormGroup, InputAdornment, TextField } from '@mui/material' import Grid from '@mui/material/Grid2' import { FC } from 'react' import { useForm } from 'react-hook-form' +import { handleApiCall } from '../../api/Api' import { Ticker, TickerSignalGroupAdminFormData, putTickerSignalGroupAdminApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' @@ -26,19 +27,22 @@ const SignalGroupAdminForm: FC = ({ callback, ticker, setSubmitting }) => const onSubmit = handleSubmit(data => { setSubmitting(true) - putTickerSignalGroupAdminApi(token, data, ticker) - .then(response => { - if (response.status == 'error') { - createNotification({ content: 'Failed to add number to Signal group', severity: 'error' }) - setError('number', { message: 'Failed to add number to Signal group' }) - } else { - createNotification({ content: 'Number successfully added to Signal group', severity: 'success' }) - callback() - } - }) - .finally(() => { - setSubmitting(false) - }) + + handleApiCall(putTickerSignalGroupAdminApi(token, data, ticker), { + onSuccess: () => { + createNotification({ content: 'Number successfully added to Signal group', severity: 'success' }) + callback() + }, + onError: () => { + createNotification({ content: 'Failed to add number to Signal group', severity: 'error' }) + setError('number', { message: 'Failed to add number to Signal group' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, + }) + + setSubmitting(false) }) return ( diff --git a/src/components/ticker/SignalGroupCard.test.tsx b/src/components/ticker/SignalGroupCard.test.tsx index 3490335..67c8315 100644 --- a/src/components/ticker/SignalGroupCard.test.tsx +++ b/src/components/ticker/SignalGroupCard.test.tsx @@ -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 SignalGroupCard from './SignalGroupCard' -const token = sign({ id: 1, email: 'user@example.org', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret') - describe('SignalGroupCard', () => { beforeAll(() => { - localStorage.setItem('token', token) + localStorage.setItem('token', userToken) + }) + + beforeEach(() => { + fetchMock.resetMocks() }) const ticker = ({ active, connected }: { active: boolean; connected: boolean }) => { @@ -25,33 +23,12 @@ describe('SignalGroupCard', () => { } as Ticker } - beforeEach(() => { - fetchMock.resetMocks() - }) - - function setup(ticker: Ticker) { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return render( - - - - - - - - - - ) + const component = ({ ticker }: { ticker: Ticker }) => { + return } it('should render the component', async () => { - setup(ticker({ active: false, connected: false })) + setup(queryClient, component({ ticker: ticker({ active: false, connected: false }) })) expect(screen.getByText('Signal Group')).toBeInTheDocument() expect(screen.getByText("You don't have a Signal group connected.")).toBeInTheDocument() @@ -66,14 +43,14 @@ describe('SignalGroupCard', () => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: 'Bearer ' + token, + Authorization: 'Bearer ' + userToken, }, method: 'put', }) }) it('should render the component when connected and active', async () => { - setup(ticker({ active: true, connected: true })) + setup(queryClient, component({ ticker: ticker({ active: true, connected: true }) })) expect(screen.getByText('Signal Group')).toBeInTheDocument() expect(screen.getByText('You have a Signal group connected.')).toBeInTheDocument() @@ -89,7 +66,7 @@ describe('SignalGroupCard', () => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: 'Bearer ' + token, + Authorization: 'Bearer ' + userToken, }, method: 'put', }) @@ -108,7 +85,7 @@ describe('SignalGroupCard', () => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: 'Bearer ' + token, + Authorization: 'Bearer ' + userToken, }, method: 'delete', }) diff --git a/src/components/ticker/SignalGroupCard.tsx b/src/components/ticker/SignalGroupCard.tsx index 9450e29..0a4a640 100644 --- a/src/components/ticker/SignalGroupCard.tsx +++ b/src/components/ticker/SignalGroupCard.tsx @@ -19,7 +19,8 @@ import { 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, deleteTickerSignalGroupApi, putTickerSignalGroupApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' @@ -44,24 +45,41 @@ const SignalGroupCard: FC = ({ ticker }) => { const handleAdd = () => { setSubmittingAdd(true) - putTickerSignalGroupApi(token, { active: true }, ticker) - .finally(() => { + + handleApiCall(putTickerSignalGroupApi(token, { active: true }, ticker), { + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) createNotification({ content: 'Signal Group enabled successfully', severity: 'success' }) - setSubmittingAdd(false) - }) - .catch(() => { + }, + onError: () => { createNotification({ content: 'Failed to configure Signal group', severity: 'error' }) - }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, + }) + + setSubmittingAdd(false) } - const handleToggle = useCallback(() => { + const handleToggle = () => { setSubmittingToggle(true) - putTickerSignalGroupApi(token, { active: !signalGroup.active }, ticker).finally(() => { - queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - setSubmittingToggle(false) + + handleApiCall(putTickerSignalGroupApi(token, { active: !signalGroup.active }, ticker), { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + createNotification({ content: `Signal group ${signalGroup.active ? 'disabled' : 'enabled'} successfully`, severity: 'success' }) + }, + onError: () => { + createNotification({ content: 'Failed to update Signal group', severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, }) - }, [token, queryClient, signalGroup.active, ticker]) + + setSubmittingToggle(false) + } const handleDelete = () => { setSubmittingDelete(true) diff --git a/src/components/ticker/TelegramCard.test.tsx b/src/components/ticker/TelegramCard.test.tsx index 45f6443..a8f5c82 100644 --- a/src/components/ticker/TelegramCard.test.tsx +++ b/src/components/ticker/TelegramCard.test.tsx @@ -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 TelegramCard from './TelegramCard' -const token = sign({ id: 1, email: 'user@example.org', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret') - describe('TelegramCard', () => { beforeAll(() => { - localStorage.setItem('token', token) + localStorage.setItem('token', userToken) + }) + + beforeEach(() => { + fetchMock.resetMocks() }) const ticker = ({ active, connected, channelName = '' }: { active: boolean; connected: boolean; channelName?: string }) => { @@ -27,33 +25,12 @@ describe('TelegramCard', () => { } as Ticker } - beforeEach(() => { - fetchMock.resetMocks() - }) - - function setup(ticker: Ticker) { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return render( - - - - - - - - - - ) + const component = ({ ticker }: { ticker: Ticker }) => { + return } it('should render the component', () => { - setup(ticker({ active: false, connected: false })) + setup(queryClient, component({ ticker: ticker({ active: false, connected: false }) })) expect(screen.getByText('Telegram')).toBeInTheDocument() expect(screen.getByText('You are not connected with Telegram.')).toBeInTheDocument() @@ -61,7 +38,7 @@ describe('TelegramCard', () => { }) it('should render the component when connected and active', async () => { - setup(ticker({ active: true, connected: true, channelName: 'channel' })) + setup(queryClient, component({ ticker: ticker({ active: true, connected: true, channelName: 'channel' }) })) expect(screen.getByText('Telegram')).toBeInTheDocument() expect(screen.getByText('You are connected with Telegram.')).toBeInTheDocument() @@ -80,7 +57,7 @@ describe('TelegramCard', () => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: 'Bearer ' + token, + Authorization: 'Bearer ' + userToken, }, method: 'put', }) @@ -94,7 +71,7 @@ describe('TelegramCard', () => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: 'Bearer ' + token, + Authorization: 'Bearer ' + userToken, }, method: 'delete', }) diff --git a/src/components/ticker/TelegramCard.tsx b/src/components/ticker/TelegramCard.tsx index 91d1751..abd33e5 100644 --- a/src/components/ticker/TelegramCard.tsx +++ b/src/components/ticker/TelegramCard.tsx @@ -3,7 +3,8 @@ 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, deleteTickerTelegramApi, putTickerTelegramApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' @@ -21,19 +22,35 @@ const TelegramCard: FC = ({ ticker }) => { const telegram = ticker.telegram - const handleToggle = useCallback(() => { - putTickerTelegramApi(token, { active: !telegram.active }, ticker).finally(() => { - queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: `Telegram integration ${telegram.active ? 'disabled' : 'enabled'} successfully`, severity: 'success' }) + const handleToggle = () => { + handleApiCall(putTickerTelegramApi(token, { active: !telegram.active }, ticker), { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + createNotification({ content: `Telegram integration ${telegram.active ? 'disabled' : 'enabled'} successfully`, severity: 'success' }) + }, + onError: () => { + createNotification({ content: 'Failed to update Telegram integration', severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, }) - }, [token, telegram.active, ticker, queryClient, createNotification]) + } - const handleDelete = useCallback(() => { - deleteTickerTelegramApi(token, ticker).finally(() => { - queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: 'Telegram integration successfully deleted', severity: 'success' }) + const handleDelete = () => { + handleApiCall(deleteTickerTelegramApi(token, ticker), { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + createNotification({ content: 'Telegram integration successfully deleted', severity: 'success' }) + }, + onError: () => { + createNotification({ content: 'Failed to delete Telegram integration', severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, }) - }, [token, ticker, queryClient, createNotification]) + } const channelLink = ( diff --git a/src/components/ticker/TelegramForm.test.tsx b/src/components/ticker/TelegramForm.test.tsx index 72b84c3..d4e0c06 100644 --- a/src/components/ticker/TelegramForm.test.tsx +++ b/src/components/ticker/TelegramForm.test.tsx @@ -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 TelegramForm from './TelegramForm' -const token = sign({ id: 1, email: 'user@example.org', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret') - describe('TelegramForm', () => { beforeAll(() => { - localStorage.setItem('token', token) + localStorage.setItem('token', userToken) + }) + + beforeEach(() => { + fetchMock.resetMocks() }) const ticker = ({ active, connected, channelName = '' }: { active: boolean; connected: boolean; channelName?: string }) => { @@ -29,34 +27,17 @@ describe('TelegramForm', () => { const callback = vi.fn() - beforeEach(() => { - fetchMock.resetMocks() - }) - - function setup(ticker: Ticker) { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return render( - - - - - - - - - - + const component = ({ ticker }: { ticker: Ticker }) => { + return ( + <> + + + ) } it('should render the component', async () => { - setup(ticker({ active: false, connected: false })) + setup(queryClient, component({ ticker: ticker({ active: false, connected: false }) })) expect(screen.getByRole('checkbox', { name: 'Active' })).toBeInTheDocument() expect(screen.getByLabelText('Channel *')).toBeInTheDocument() @@ -75,7 +56,7 @@ describe('TelegramForm', () => { body: JSON.stringify({ active: true, channelName: '@channel' }), headers: { Accept: 'application/json', - Authorization: 'Bearer ' + token, + Authorization: 'Bearer ' + userToken, 'Content-Type': 'application/json', }, method: 'put', diff --git a/src/components/ticker/TelegramForm.tsx b/src/components/ticker/TelegramForm.tsx index 3e34e57..580b8d7 100644 --- a/src/components/ticker/TelegramForm.tsx +++ b/src/components/ticker/TelegramForm.tsx @@ -3,6 +3,7 @@ import Grid from '@mui/material/Grid2' import { useQueryClient } from '@tanstack/react-query' import { FC } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' +import { handleApiCall } from '../../api/Api' import { Ticker, putTickerTelegramApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' @@ -30,10 +31,18 @@ const TelegramForm: FC = ({ callback, ticker }) => { const queryClient = useQueryClient() const onSubmit: SubmitHandler = data => { - putTickerTelegramApi(token, data, ticker).finally(() => { - queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: 'Telegram integration was successfully updated', severity: 'success' }) - callback() + handleApiCall(putTickerTelegramApi(token, data, ticker), { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + createNotification({ content: 'Telegram integration was successfully updated', severity: 'success' }) + callback() + }, + onError: () => { + createNotification({ content: 'Failed to update Telegram integration', severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, }) } diff --git a/src/components/ticker/TickerList.test.tsx b/src/components/ticker/TickerList.test.tsx index eaab657..e7edc97 100644 --- a/src/components/ticker/TickerList.test.tsx +++ b/src/components/ticker/TickerList.test.tsx @@ -1,7 +1,4 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render } from '@testing-library/react' -import { MemoryRouter } from 'react-router' -import { AuthProvider } from '../../contexts/AuthContext' +import { queryClient, setup, userToken } from '../../tests/utils' import TickerList from './TickerList' import TickerListItems from './TickerListItems' @@ -15,23 +12,8 @@ describe('TickerList', function () { vi.restoreAllMocks() }) - function setup() { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return render( - - - - - - - - ) + const component = () => { + return } it('should render', async function () { @@ -42,7 +24,7 @@ describe('TickerList', function () { } }) - setup() + setup(queryClient, component()) expect(TickerListItems).toHaveBeenCalledTimes(3) }) diff --git a/src/components/ticker/TickerListItems.test.tsx b/src/components/ticker/TickerListItems.test.tsx index 05221a8..de39b47 100644 --- a/src/components/ticker/TickerListItems.test.tsx +++ b/src/components/ticker/TickerListItems.test.tsx @@ -1,66 +1,34 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen } from '@testing-library/react' -import sign from 'jwt-encode' -import { MemoryRouter } from 'react-router' +import { screen } from '@testing-library/react' import { GetTickersQueryParams } from '../../api/Ticker' -import { AuthProvider } from '../../contexts/AuthContext' -import { NotificationProvider } from '../../contexts/NotificationContext' +import { adminToken, queryClient, setup } from '../../tests/utils' import TickerListItems from './TickerListItems' describe('TickerListItems', function () { + beforeAll(() => { + localStorage.setItem('token', adminToken) + }) + beforeEach(() => { fetchMock.resetMocks() }) - function jwt(role: string): string { - return sign( - { - id: 1, - email: 'louis@systemli.org', - roles: role === 'admin' ? ['admin', 'user'] : ['user'], - exp: new Date().getTime() / 1000 + 600, - }, - 'secret' - ) + const component = ({ params }: { params: GetTickersQueryParams }) => { + return } - function setup(params: GetTickersQueryParams) { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return render( - - - - - - - - - - ) - } + const params = { title: '', origin: '', active: undefined } as GetTickersQueryParams it('should render zero tickers', async function () { - vi.spyOn(window.localStorage.__proto__, 'getItem').mockReturnValue(jwt('admin')) fetchMock.mockResponseOnce(JSON.stringify({ data: { tickers: [] }, status: 'success' })) - const params = { title: '', origin: '', active: undefined } as GetTickersQueryParams - setup(params) + setup(queryClient, component({ params })) expect(screen.getByText('Loading')).toBeInTheDocument() - expect(fetchMock).toHaveBeenCalledTimes(1) - expect(await screen.findByText('No tickers found.')).toBeInTheDocument() }) - it('should render tickers for admin', async function () { - vi.spyOn(window.localStorage.__proto__, 'getItem').mockReturnValue(jwt('admin')) + it('should render tickers', async function () { fetchMock.mockResponseOnce( JSON.stringify({ data: { @@ -79,55 +47,19 @@ describe('TickerListItems', function () { }) ) - const params = { title: '', origin: '', active: undefined } as GetTickersQueryParams - setup(params) - - expect(screen.getByText('Loading')).toBeInTheDocument() + setup(queryClient, component({ params })) expect(fetchMock).toHaveBeenCalledTimes(1) - expect(await screen.findByText('title')).toBeInTheDocument() expect(await screen.findByText('http://localhost')).toBeInTheDocument() }) - it('should render tickers for user', async function () { - vi.spyOn(window.localStorage.__proto__, 'getItem').mockReturnValue(jwt('user')) - fetchMock.mockResponseOnce( - JSON.stringify({ - data: { - tickers: [ - { - id: 1, - createdAt: new Date(), - title: 'title', - description: 'description', - active: true, - }, - ], - }, - status: 'success', - }) - ) - - const params = { title: '', origin: '', active: undefined } as GetTickersQueryParams - setup(params) - - expect(screen.getByText('Loading')).toBeInTheDocument() - - expect(fetchMock).toHaveBeenCalledTimes(1) - }) - it('should render error message', async function () { - vi.spyOn(window.localStorage.__proto__, 'getItem').mockReturnValue(jwt('admin')) - fetchMock.mockRejectOnce(new Error('bad url')) - - const params = { title: '', origin: '', active: undefined } as GetTickersQueryParams - setup(params) + fetchMock.mockResponseOnce(JSON.stringify({ status: 'error', error: { code: 500, message: 'Internal Server Error' } })) - expect(screen.getByText('Loading')).toBeInTheDocument() + setup(queryClient, component({ params })) expect(fetchMock).toHaveBeenCalledTimes(1) - expect(await screen.findByText('Unable to fetch tickers from server.')).toBeInTheDocument() }) }) diff --git a/src/components/ticker/TickerModalDelete.tsx b/src/components/ticker/TickerModalDelete.tsx index 3f4fc17..cde26a5 100644 --- a/src/components/ticker/TickerModalDelete.tsx +++ b/src/components/ticker/TickerModalDelete.tsx @@ -1,5 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import { FC, useCallback } from 'react' +import { handleApiCall } from '../../api/Api' import { Ticker, deleteTickerApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' @@ -17,9 +18,17 @@ const TickerModalDelete: FC = ({ open, onClose, ticker }) => { const queryClient = useQueryClient() const handleDelete = useCallback(() => { - deleteTickerApi(token, ticker).finally(() => { - queryClient.invalidateQueries({ queryKey: ['tickers'] }) - createNotification({ content: 'Ticker was successfully deleted', severity: 'success' }) + handleApiCall(deleteTickerApi(token, ticker), { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tickers'] }) + createNotification({ content: 'Ticker was successfully deleted', severity: 'success' }) + }, + onError: () => { + createNotification({ content: 'Failed to delete ticker', severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, }) }, [token, ticker, queryClient, createNotification]) diff --git a/src/components/ticker/TickerResetModal.test.tsx b/src/components/ticker/TickerResetModal.test.tsx index 61cef30..45e8775 100644 --- a/src/components/ticker/TickerResetModal.test.tsx +++ b/src/components/ticker/TickerResetModal.test.tsx @@ -1,18 +1,12 @@ -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 TickerResetModal from './TickerResetModal' -const token = sign({ id: 1, email: 'user@example.org', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret') - describe('TickerResetModal', () => { beforeAll(() => { - localStorage.setItem('token', token) + localStorage.setItem('token', userToken) }) beforeEach(() => { @@ -22,33 +16,17 @@ describe('TickerResetModal', () => { const onClose = vi.fn() - function setup(ticker: Ticker) { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return render( - - - - - - - - - - ) + const component = ({ ticker }: { ticker: Ticker }) => { + return } + const ticker = { + id: 1, + title: 'Ticker 1', + } as Ticker + it('should render the component', async () => { - const ticker = { - id: 1, - title: 'Ticker 1', - } as Ticker - setup(ticker) + setup(queryClient, component({ ticker })) expect(screen.getByRole('button', { name: 'Reset' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() @@ -64,17 +42,13 @@ describe('TickerResetModal', () => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${userToken}`, }, }) }) it('should render the component and close the modal', async () => { - const ticker = { - id: 1, - title: 'Ticker 1', - } as Ticker - setup(ticker) + setup(queryClient, component({ ticker })) fetchMock.mockResponseOnce(JSON.stringify({ status: 'success' })) diff --git a/src/components/ticker/TickerResetModal.tsx b/src/components/ticker/TickerResetModal.tsx index 913a42e..0d37aef 100644 --- a/src/components/ticker/TickerResetModal.tsx +++ b/src/components/ticker/TickerResetModal.tsx @@ -1,5 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import { FC, useCallback } from 'react' +import { handleApiCall } from '../../api/Api' import { Ticker, putTickerResetApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' @@ -17,16 +18,21 @@ const TickerResetModal: FC = ({ onClose, open, ticker }) => { const queryClient = useQueryClient() const handleReset = useCallback(() => { - putTickerResetApi(token, ticker) - .then(() => { + handleApiCall(putTickerResetApi(token, ticker), { + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['messages', ticker.id] }) queryClient.invalidateQueries({ queryKey: ['tickerUsers', ticker.id] }) queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - }) - .finally(() => { createNotification({ content: 'Ticker has been successfully reset', severity: 'success' }) onClose() - }) + }, + onError: () => { + createNotification({ content: 'Failed to reset ticker', severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, + }) }, [token, ticker, queryClient, createNotification, onClose]) return ( diff --git a/src/components/ticker/TickerUserModalDelete.test.tsx b/src/components/ticker/TickerUserModalDelete.test.tsx index f3bc816..1325a1b 100644 --- a/src/components/ticker/TickerUserModalDelete.test.tsx +++ b/src/components/ticker/TickerUserModalDelete.test.tsx @@ -1,56 +1,37 @@ -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 { User } from '../../api/User' -import { AuthProvider } from '../../contexts/AuthContext' -import { NotificationProvider } from '../../contexts/NotificationContext' +import { queryClient, setup, userToken } from '../../tests/utils' import TickerUserModalDelete from './TickerUserModalDelete' -const token = sign({ id: 1, email: 'user@example.org', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret') - describe('TickerUserModalDelete', () => { beforeAll(() => { - localStorage.setItem('token', token) + localStorage.setItem('token', userToken) }) beforeEach(() => { fetchMock.resetMocks() + onClose.mockClear() }) - function setup(ticker: Ticker, user: User, open: boolean) { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return render( - - - - - - - - - - ) + const onClose = vi.fn() + + const component = ({ ticker, user, open }: { ticker: Ticker; user: User; open: boolean }) => { + return } + const ticker = { + id: 1, + title: 'Ticker 1', + } as Ticker + const user = { + id: 1, + email: 'user@example.org', + } as User + it('should render the component', async () => { - const ticker = { - id: 1, - title: 'Ticker 1', - } as Ticker - const user = { - id: 1, - email: 'user@example.org', - } as User - setup(ticker, user, true) + setup(queryClient, component({ ticker, user, open: true })) expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument() @@ -62,10 +43,21 @@ describe('TickerUserModalDelete', () => { expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/v1/admin/tickers/1/users/1', { headers: { Accept: 'application/json', - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${userToken}`, 'Content-Type': 'application/json', }, method: 'delete', }) }) + + it('should close the modal', async () => { + setup(queryClient, component({ ticker, user, open: true })) + + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Close' })) + + expect(onClose).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/components/ticker/TickerUserModalDelete.tsx b/src/components/ticker/TickerUserModalDelete.tsx index a60ce86..e8dbcac 100644 --- a/src/components/ticker/TickerUserModalDelete.tsx +++ b/src/components/ticker/TickerUserModalDelete.tsx @@ -1,5 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import { FC, useCallback } from 'react' +import { handleApiCall } from '../../api/Api' import { Ticker, deleteTickerUserApi } from '../../api/Ticker' import { User } from '../../api/User' import useAuth from '../../contexts/useAuth' @@ -19,10 +20,18 @@ const TickerUserModalDelete: FC = ({ open, onClose, ticker, user }) => { const queryClient = useQueryClient() const handleDelete = useCallback(() => { - deleteTickerUserApi(token, ticker, user).finally(() => { - queryClient.invalidateQueries({ queryKey: ['tickerUsers', ticker.id] }) - createNotification({ content: 'User is successfully deleted from ticker', severity: 'success' }) - onClose() + handleApiCall(deleteTickerUserApi(token, ticker, user), { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tickerUsers', ticker.id] }) + createNotification({ content: 'User is successfully deleted from ticker', severity: 'success' }) + onClose() + }, + onError: () => { + createNotification({ content: 'Failed to delete user from ticker', severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, }) }, [token, ticker, user, queryClient, createNotification, onClose]) diff --git a/src/components/ticker/TickerUsersForm.test.tsx b/src/components/ticker/TickerUsersForm.test.tsx index 3eb5bdd..1da11c0 100644 --- a/src/components/ticker/TickerUsersForm.test.tsx +++ b/src/components/ticker/TickerUsersForm.test.tsx @@ -1,20 +1,14 @@ -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 { vi } from 'vitest' import { Ticker } from '../../api/Ticker' import { User } from '../../api/User' -import { AuthProvider } from '../../contexts/AuthContext' -import { NotificationProvider } from '../../contexts/NotificationContext' +import { queryClient, setup, userToken } from '../../tests/utils' import TickerUsersForm from './TickerUsersForm' -const token = sign({ id: 1, email: 'user@example.org', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret') - describe('TickerUsersForm', () => { beforeAll(() => { - localStorage.setItem('token', token) + localStorage.setItem('token', userToken) }) beforeEach(() => { @@ -24,25 +18,12 @@ describe('TickerUsersForm', () => { const handleSubmit = vi.fn() - const setup = (defaultValue: Array, ticker: Ticker, onSubmit: () => void) => { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return render( - - - - - - - - - - + const component = ({ ticker, defaultValue }: { ticker: Ticker; defaultValue: Array }) => { + return ( + <> + + + ) } @@ -65,7 +46,7 @@ describe('TickerUsersForm', () => { }) ) - setup([user], ticker, handleSubmit) + setup(queryClient, component({ ticker, defaultValue: [user] })) expect(screen.getByRole('combobox')).toBeInTheDocument() @@ -90,7 +71,7 @@ describe('TickerUsersForm', () => { body: JSON.stringify({ users: [] }), headers: { Accept: 'application/json', - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${userToken}`, 'Content-Type': 'application/json', }, method: 'put', diff --git a/src/components/ticker/TickerUsersForm.tsx b/src/components/ticker/TickerUsersForm.tsx index acbc15f..98feeda 100644 --- a/src/components/ticker/TickerUsersForm.tsx +++ b/src/components/ticker/TickerUsersForm.tsx @@ -2,6 +2,7 @@ import { Box, Chip, FormControl, InputLabel, MenuItem, OutlinedInput, Select, Se import { useQueryClient } from '@tanstack/react-query' import { FC, useEffect, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' +import { handleApiCall } from '../../api/Api' import { Ticker, putTickerUsersApi } from '../../api/Ticker' import { User, fetchUsersApi } from '../../api/User' import useAuth from '../../contexts/useAuth' @@ -37,21 +38,25 @@ const TickerUsersForm: FC = ({ onSubmit, ticker, defaultValue }) => { } const updateTickerUsers: SubmitHandler = () => { - putTickerUsersApi(token, ticker, users).then(response => { - if (response.status !== 'success') { - createNotification({ content: 'Failed to update users', severity: 'error' }) - } else { + handleApiCall(putTickerUsersApi(token, ticker, users), { + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tickerUsers', ticker.id] }) createNotification({ content: 'Users were successfully updated', severity: 'success' }) onSubmit() - } + }, + onError: () => { + createNotification({ content: 'Failed to update users', severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, }) } useEffect(() => { - fetchUsersApi(token) - .then(response => response.data?.users) - .then(users => { + handleApiCall(fetchUsersApi(token), { + onSuccess: response => { + const users = response.data?.users if (!users) { return } @@ -61,7 +66,14 @@ const TickerUsersForm: FC = ({ onSubmit, ticker, defaultValue }) => { return !user.isSuperAdmin }) ) - }) + }, + onError: () => { + createNotification({ content: 'Failed to fetch users', severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, + }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) diff --git a/src/components/ticker/TickersDropdown.test.tsx b/src/components/ticker/TickersDropdown.test.tsx index f7548b9..be41461 100644 --- a/src/components/ticker/TickersDropdown.test.tsx +++ b/src/components/ticker/TickersDropdown.test.tsx @@ -1,41 +1,27 @@ -import { render, screen } from '@testing-library/react' -import TickersDropdown from './TickersDropdown' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { Ticker } from '../../api/Ticker' -import { vi } from 'vitest' +import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { AuthProvider } from '../../contexts/AuthContext' -import { MemoryRouter } from 'react-router' +import { vi } from 'vitest' +import { Ticker } from '../../api/Ticker' +import { queryClient, setup } from '../../tests/utils' +import TickersDropdown from './TickersDropdown' describe('TickersDropdown', () => { beforeEach(() => { fetchMock.resetMocks() }) - function setup(defaultValue: Array, onChange: (tickers: Array) => void) { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return render( - - - - - - - - ) + const onChange = vi.fn() + + const component = ({ tickers }: { tickers: Array }) => { + return } it('should renders correctly', async () => { const ticker = { id: 1, title: 'Ticker 1', - } + } as Ticker + fetchMock.mockResponseOnce( JSON.stringify({ data: { @@ -44,8 +30,7 @@ describe('TickersDropdown', () => { status: 'success', }) ) - const handleChange = vi.fn() - setup([], handleChange) + setup(queryClient, component({ tickers: [] })) expect(screen.getByRole('combobox')).toBeInTheDocument() @@ -56,6 +41,6 @@ describe('TickersDropdown', () => { await userEvent.click(screen.getByText('Ticker 1')) - expect(handleChange).toHaveBeenCalledWith([ticker]) + expect(onChange).toHaveBeenCalledWith([ticker]) }) }) diff --git a/src/components/ticker/TickersDropdown.tsx b/src/components/ticker/TickersDropdown.tsx index fbbe787..f6dc91b 100644 --- a/src/components/ticker/TickersDropdown.tsx +++ b/src/components/ticker/TickersDropdown.tsx @@ -1,7 +1,9 @@ import { Box, Chip, FormControl, InputLabel, MenuItem, OutlinedInput, Select, SelectChangeEvent, SxProps, useTheme } from '@mui/material' import { FC, useEffect, useState } from 'react' +import { handleApiCall } from '../../api/Api' import { GetTickersQueryParams, Ticker, fetchTickersApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' +import useNotification from '../../contexts/useNotification' interface Props { name: string @@ -11,6 +13,7 @@ interface Props { } const TickersDropdown: FC = ({ name, defaultValue, onChange, sx }) => { + const { createNotification } = useNotification() const [options, setOptions] = useState>([]) const [tickers, setTickers] = useState>(defaultValue) const { token } = useAuth() @@ -26,13 +29,19 @@ const TickersDropdown: FC = ({ name, defaultValue, onChange, sx }) => { } useEffect(() => { - const params = {} as GetTickersQueryParams - fetchTickersApi(token, params) - .then(response => response.data?.tickers) - .then(tickers => { + handleApiCall(fetchTickersApi(token, {} as GetTickersQueryParams), { + onSuccess: response => { + const tickers = response.data?.tickers if (!tickers) return setOptions(tickers) - }) + }, + onError: () => { + createNotification({ content: 'Failed to fetch tickers', severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, + }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) diff --git a/src/components/ticker/WebsiteCard.test.tsx b/src/components/ticker/WebsiteCard.test.tsx index 2c3890f..0e34230 100644 --- a/src/components/ticker/WebsiteCard.test.tsx +++ b/src/components/ticker/WebsiteCard.test.tsx @@ -1,62 +1,43 @@ -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, TickerWebsite } from '../../api/Ticker' -import { AuthProvider } from '../../contexts/AuthContext' -import { NotificationProvider } from '../../contexts/NotificationContext' +import { queryClient, setup, userToken } from '../../tests/utils' import WebsiteCard from './WebsiteCard' -const token = sign({ id: 1, email: 'user@example.org', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret') - describe('WebsiteCard', () => { beforeAll(() => { - localStorage.setItem('token', token) + localStorage.setItem('token', userToken) }) - const ticker = ({ websites }: { websites: Array }) => { - return { - id: 1, - websites: websites, - } as Ticker - } - beforeEach(() => { fetchMock.resetMocks() }) - function setup(ticker: Ticker) { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return render( - - - - - - - - - - + const component = ({ websites }: { websites: Array }) => { + return ( + <> + + + ) } it('should render the component', async () => { - setup(ticker({ websites: [] })) + setup(queryClient, component({ websites: [] })) expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument() expect(screen.getByText('No website origins configured.')).toBeInTheDocument() }) it('should delete the origins', async () => { - setup(ticker({ websites: [{ origin: 'http://localhost', id: 1 }] })) + setup(queryClient, component({ websites: [{ origin: 'http://localhost', id: 1 }] })) expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument() @@ -69,7 +50,7 @@ describe('WebsiteCard', () => { expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/v1/admin/tickers/1/websites', { headers: { Accept: 'application/json', - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${userToken}`, 'Content-Type': 'application/json', }, method: 'delete', @@ -77,7 +58,7 @@ describe('WebsiteCard', () => { }) it('should open the form', async () => { - setup(ticker({ websites: [] })) + setup(queryClient, component({ websites: [] })) expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument() @@ -85,4 +66,28 @@ describe('WebsiteCard', () => { expect(screen.getByRole('button', { name: 'Add Origin' })).toBeInTheDocument() }) + + it('should fail when reponse fails', async () => { + setup(queryClient, component({ websites: [{ origin: 'http://localhost', id: 1 }] })) + + expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument() + + fetchMock.mockResponseOnce(JSON.stringify({ status: 'error' })) + + await userEvent.click(screen.getByRole('button', { name: 'Delete' })) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('should fail when request fails', async () => { + setup(queryClient, component({ websites: [{ origin: 'http://localhost', id: 1 }] })) + + expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument() + + fetchMock.mockReject() + + await userEvent.click(screen.getByRole('button', { name: 'Delete' })) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/components/ticker/WebsiteCard.tsx b/src/components/ticker/WebsiteCard.tsx index b0ae26b..d55f0e8 100644 --- a/src/components/ticker/WebsiteCard.tsx +++ b/src/components/ticker/WebsiteCard.tsx @@ -2,9 +2,11 @@ import { faGear, faGlobe, faTrash } from '@fortawesome/free-solid-svg-icons' 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 { deleteTickerWebsitesApi, Ticker } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' +import useNotification from '../../contexts/useNotification' import WebsiteModalForm from './WebsiteModalForm' interface Props { @@ -12,6 +14,7 @@ interface Props { } const WebsiteCard: FC = ({ ticker }) => { + const { createNotification } = useNotification() const { token } = useAuth() const [open, setOpen] = useState(false) @@ -19,11 +22,20 @@ const WebsiteCard: FC = ({ ticker }) => { const websites = ticker.websites - const handleDelete = useCallback(() => { - deleteTickerWebsitesApi(token, ticker).finally(() => { - queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + const handleDelete = () => { + handleApiCall(deleteTickerWebsitesApi(token, ticker), { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + createNotification({ content: 'Websites integration successfully deleted', severity: 'success' }) + }, + onError: () => { + createNotification({ content: 'Failed to delete Websites integration', severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, }) - }, [token, queryClient, ticker]) + } const links = websites.map(website => ( diff --git a/src/components/ticker/WebsiteForm.test.tsx b/src/components/ticker/WebsiteForm.test.tsx index c862a49..38ef708 100644 --- a/src/components/ticker/WebsiteForm.test.tsx +++ b/src/components/ticker/WebsiteForm.test.tsx @@ -1,57 +1,40 @@ -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, TickerWebsite } from '../../api/Ticker' -import { AuthProvider } from '../../contexts/AuthContext' -import { NotificationProvider } from '../../contexts/NotificationContext' +import { queryClient, setup, userToken } from '../../tests/utils' import WebsiteForm from './WebsiteForm' -const token = sign({ id: 1, email: 'user@example.org', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret') - describe('WebsiteForm', () => { beforeAll(() => { - localStorage.setItem('token', token) + localStorage.setItem('token', userToken) }) - const ticker = ({ websites }: { websites: Array }) => { - return { - id: 1, - websites: websites, - } as Ticker - } - - const callback = vi.fn() - beforeEach(() => { fetchMock.resetMocks() + callback.mockClear() }) - function setup(ticker: Ticker) { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return render( - - - - - - - - - - + const callback = vi.fn() + + const component = ({ websites }: { websites: Array }) => { + return ( + <> + + + ) } it('should render the component', async () => { - setup(ticker({ websites: [] })) + setup(queryClient, component({ websites: [] })) expect(screen.getByRole('button', { name: 'Add Origin' })).toBeInTheDocument() @@ -71,10 +54,46 @@ describe('WebsiteForm', () => { body: JSON.stringify({ websites: [{ origin: 'https://example.com' }] }), headers: { Accept: 'application/json', - Authorization: 'Bearer ' + token, + Authorization: 'Bearer ' + userToken, 'Content-Type': 'application/json', }, method: 'put', }) }) + + it('should fail when URL is already exists', async () => { + setup(queryClient, component({ websites: [] })) + + expect(screen.getByRole('button', { name: 'Add Origin' })).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Add Origin' })) + + expect(screen.getByPlaceholderText('https://example.com')).toBeInTheDocument() + + await userEvent.type(screen.getByPlaceholderText('https://example.com'), 'https://example.com') + + fetchMock.mockResponseOnce(JSON.stringify({ status: 'error' })) + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('should fail when request fails', async () => { + setup(queryClient, component({ websites: [] })) + + expect(screen.getByRole('button', { name: 'Add Origin' })).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Add Origin' })) + + expect(screen.getByPlaceholderText('https://example.com')).toBeInTheDocument() + + await userEvent.type(screen.getByPlaceholderText('https://example.com'), 'https://example.com') + + fetchMock.mockRejectOnce() + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/components/ticker/WebsiteForm.tsx b/src/components/ticker/WebsiteForm.tsx index 6ab4a6a..8b5ea29 100644 --- a/src/components/ticker/WebsiteForm.tsx +++ b/src/components/ticker/WebsiteForm.tsx @@ -4,6 +4,7 @@ import Grid from '@mui/material/Grid2' import { useQueryClient } from '@tanstack/react-query' import { FC } from 'react' import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form' +import { handleApiCall } from '../../api/Api' import { putTickerWebsitesApi, Ticker, TickerWebsite } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' @@ -37,10 +38,18 @@ const WebsiteForm: FC = ({ callback, ticker }) => { const queryClient = useQueryClient() const onSubmit: SubmitHandler = data => { - putTickerWebsitesApi(token, ticker, data.websites).finally(() => { - queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: 'Websites were successfully updated', severity: 'success' }) - callback() + handleApiCall(putTickerWebsitesApi(token, ticker, data.websites), { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) + createNotification({ content: 'Websites were successfully updated', severity: 'success' }) + callback() + }, + onError: () => { + createNotification({ content: 'Failed to update websites', severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, }) } diff --git a/src/components/ticker/form/TickerForm.test.tsx b/src/components/ticker/form/TickerForm.test.tsx index ff3f44c..8dbe51b 100644 --- a/src/components/ticker/form/TickerForm.test.tsx +++ b/src/components/ticker/form/TickerForm.test.tsx @@ -1,66 +1,52 @@ -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 TickerForm from './TickerForm' -const token = sign({ id: 1, email: 'user@example.org', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret') - describe('TickerForm', () => { beforeAll(() => { - localStorage.setItem('token', token) + localStorage.setItem('token', userToken) }) - const callback = vi.fn() - const setSubmitting = vi.fn() - beforeEach(() => { fetchMock.resetMocks() callback.mockClear() setSubmitting.mockClear() }) - function setup(ticker?: Ticker) { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return render( - - - - - - - - - - + const callback = vi.fn() + const setSubmitting = vi.fn() + + const component = ({ ticker }: { ticker?: Ticker }) => { + return ( + <> + + + ) } it('should render the component', async () => { - setup({ - id: 1, - title: 'Ticker', - active: false, - information: {}, - location: {}, - } as Ticker) + setup( + queryClient, + component({ + ticker: { + id: 1, + title: 'Ticker', + active: false, + information: {}, + location: {}, + } as Ticker, + }) + ) expect(screen.getByRole('textbox', { name: 'Title' })).toBeInTheDocument() expect(screen.getByRole('checkbox', { name: 'Active' })).toBeInTheDocument() }) it('should submit for new ticker', async () => { - setup() + setup(queryClient, component({ ticker: undefined })) fetchMock.mockResponseOnce(JSON.stringify({ status: 'success' })) @@ -104,7 +90,7 @@ describe('TickerForm', () => { }), headers: { Accept: 'application/json', - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${userToken}`, 'Content-Type': 'application/json', }, method: 'post', @@ -112,13 +98,18 @@ describe('TickerForm', () => { }) it('should submit for existing ticker', async () => { - setup({ - id: 1, - title: 'Ticker', - active: false, - information: {}, - location: {}, - } as Ticker) + setup( + queryClient, + component({ + ticker: { + id: 1, + title: 'Ticker', + active: false, + information: {}, + location: {}, + } as Ticker, + }) + ) fetchMock.mockResponseOnce(JSON.stringify({ status: 'success' })) @@ -163,7 +154,7 @@ describe('TickerForm', () => { }), headers: { Accept: 'application/json', - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${userToken}`, 'Content-Type': 'application/json', }, method: 'put', diff --git a/src/components/ticker/form/TickerForm.tsx b/src/components/ticker/form/TickerForm.tsx index f6263b9..3e1cf1b 100644 --- a/src/components/ticker/form/TickerForm.tsx +++ b/src/components/ticker/form/TickerForm.tsx @@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query' import React, { FC, useCallback, useEffect } from 'react' import { FormProvider, SubmitHandler, useForm } from 'react-hook-form' import { MapContainer, Marker, TileLayer } from 'react-leaflet' +import { handleApiCall } from '../../../api/Api' import { postTickerApi, putTickerApi, Ticker, TickerFormData } from '../../../api/Ticker' import useAuth from '../../../contexts/useAuth' import useNotification from '../../../contexts/useNotification' @@ -78,22 +79,25 @@ const TickerForm: FC = ({ callback, id, ticker, setSubmitting }) => { const onSubmit: SubmitHandler = data => { setSubmitting(true) - if (ticker) { - putTickerApi(token, data, ticker.id).finally(() => { - queryClient.invalidateQueries({ queryKey: ['tickers'] }) - queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - setSubmitting(false) - createNotification({ content: 'Ticker was successfully updated', severity: 'success' }) - callback() - }) - } else { - postTickerApi(token, data).finally(() => { + + const apiCall = ticker ? putTickerApi(token, data, ticker.id) : postTickerApi(token, data) + + handleApiCall(apiCall, { + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tickers'] }) - setSubmitting(false) - createNotification({ content: 'Ticker was successfully created', severity: 'success' }) + queryClient.invalidateQueries({ queryKey: ['ticker', ticker?.id] }) + createNotification({ content: `Ticker was successfully ${ticker ? 'updated' : 'created'}`, severity: 'success' }) callback() - }) - } + }, + onError: () => { + createNotification({ content: `Failed to ${ticker ? 'update' : 'create'} ticker`, severity: 'error' }) + }, + onFailure: error => { + createNotification({ content: error as string, severity: 'error' }) + }, + }) + + setSubmitting(false) } useEffect(() => { diff --git a/src/tests/utils.tsx b/src/tests/utils.tsx new file mode 100644 index 0000000..bc224ec --- /dev/null +++ b/src/tests/utils.tsx @@ -0,0 +1,30 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render } from '@testing-library/react' +import sign from 'jwt-encode' +import { ReactNode } from 'react' +import { MemoryRouter } from 'react-router' +import { AuthProvider } from '../contexts/AuthContext' +import { NotificationProvider } from '../contexts/NotificationContext' + +export const userToken = sign({ id: 1, email: 'user@example.org', roles: ['user'], exp: new Date().getTime() / 1000 + 600 }, 'secret') +export const adminToken = sign({ id: 1, email: 'admin@example.org', roles: ['admin', 'user'], exp: new Date().getTime() / 1000 + 600 }, 'secret') + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +export const setup = (client: QueryClient, children: ReactNode) => { + return render( + + + + {children} + + + + ) +} diff --git a/vitest-setup.ts b/vitest-setup.ts index 35c3435..4879f53 100644 --- a/vitest-setup.ts +++ b/vitest-setup.ts @@ -1,6 +1,6 @@ import '@testing-library/jest-dom' -import createFetchMock from 'vitest-fetch-mock' import { vi } from 'vitest' +import createFetchMock from 'vitest-fetch-mock' const fetchMocker = createFetchMock(vi)