From 20cb890415b2017441cb18cb65416086090eb73b Mon Sep 17 00:00:00 2001 From: louis Date: Sun, 19 May 2024 21:03:40 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Introduce=20an=20apiClient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/Api.test.ts | 33 + src/api/Api.ts | 39 +- src/api/Auth.test.ts | 17 +- src/api/Features.test.ts | 34 ++ src/api/Features.ts | 18 +- src/api/Message.test.ts | 167 +++++ src/api/Message.ts | 79 ++- src/api/Settings.test.ts | 250 ++++++++ src/api/Settings.ts | 59 +- src/api/Ticker.test.ts | 576 ++++++++++++++++++ src/api/Ticker.ts | 198 +++--- src/api/Upload.test.ts | 34 ++ src/api/Upload.ts | 19 +- src/api/User.test.ts | 212 +++++++ src/api/User.ts | 70 +-- src/components/message/MessageForm.test.tsx | 57 ++ src/components/message/MessageForm.tsx | 27 +- src/components/message/MessageList.tsx | 24 +- src/components/message/MessageModalDelete.tsx | 9 +- src/components/message/UploadButton.tsx | 12 +- .../settings/InactiveSettingsCard.tsx | 20 +- .../settings/InactiveSettingsForm.tsx | 9 +- .../settings/RefreshIntervalCard.tsx | 16 +- .../settings/RefreshIntervalForm.tsx | 9 +- src/components/ticker/BlueskyCard.tsx | 21 +- src/components/ticker/BlueskyForm.tsx | 11 +- src/components/ticker/MastodonCard.test.tsx | 104 ++++ src/components/ticker/MastodonCard.tsx | 28 +- src/components/ticker/MastodonForm.test.tsx | 94 +++ src/components/ticker/MastodonForm.tsx | 5 +- src/components/ticker/TelegramCard.test.tsx | 103 ++++ src/components/ticker/TelegramCard.tsx | 20 +- src/components/ticker/TelegramForm.test.tsx | 83 +++ src/components/ticker/TelegramForm.tsx | 9 +- src/components/ticker/TickerListItems.tsx | 4 +- src/components/ticker/TickerModalDelete.tsx | 9 +- src/components/ticker/TickerResetModal.tsx | 9 +- .../ticker/TickerUserModalDelete.tsx | 9 +- src/components/ticker/TickerUsersCard.tsx | 24 +- src/components/ticker/TickerUsersForm.tsx | 24 +- src/components/ticker/TickersDropdown.tsx | 10 +- src/components/ticker/form/TickerForm.tsx | 52 +- .../user/UserChangePasswordForm.tsx | 7 +- .../user/UserChangePasswordModalForm.test.tsx | 14 +- src/components/user/UserForm.tsx | 13 +- src/components/user/UserList.tsx | 12 +- src/components/user/UserModalDelete.tsx | 9 +- src/contexts/FeatureContext.test.tsx | 18 +- src/contexts/FeatureContext.tsx | 57 +- src/contexts/useFeature.test.tsx | 8 +- src/queries/useInactiveSettingsQuery.tsx | 12 + src/queries/useMessagesQuery.tsx | 23 + .../useRefreshIntervalSettingsQuery.tsx | 12 + src/queries/useTickerQuery.tsx | 6 +- src/queries/useTickerUsersQuery.tsx | 16 + src/queries/useTickersQuery.tsx | 6 +- src/queries/useUsersQuery.tsx | 12 + src/views/HomeView.test.tsx | 3 - src/views/HomeView.tsx | 2 +- 59 files changed, 2276 insertions(+), 561 deletions(-) create mode 100644 src/api/Api.test.ts create mode 100644 src/api/Features.test.ts create mode 100644 src/api/Message.test.ts create mode 100644 src/api/Settings.test.ts create mode 100644 src/api/Ticker.test.ts create mode 100644 src/api/Upload.test.ts create mode 100644 src/api/User.test.ts create mode 100644 src/components/message/MessageForm.test.tsx create mode 100644 src/components/ticker/MastodonCard.test.tsx create mode 100644 src/components/ticker/MastodonForm.test.tsx create mode 100644 src/components/ticker/TelegramCard.test.tsx create mode 100644 src/components/ticker/TelegramForm.test.tsx create mode 100644 src/queries/useInactiveSettingsQuery.tsx create mode 100644 src/queries/useMessagesQuery.tsx create mode 100644 src/queries/useRefreshIntervalSettingsQuery.tsx create mode 100644 src/queries/useTickerUsersQuery.tsx create mode 100644 src/queries/useUsersQuery.tsx diff --git a/src/api/Api.test.ts b/src/api/Api.test.ts new file mode 100644 index 00000000..9ab6d993 --- /dev/null +++ b/src/api/Api.test.ts @@ -0,0 +1,33 @@ +import { apiClient, apiHeaders } from './Api' + +describe('apiClient', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { features: { telegramEnabled: true } } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await apiClient('/admin/features', { headers: apiHeaders('token') }) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith('/admin/features', { headers: apiHeaders('token') }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await apiClient('/admin/features', { headers: apiHeaders('token') }) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith('/admin/features', { headers: apiHeaders('token') }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await apiClient('/admin/features', { headers: apiHeaders('token') }) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith('/admin/features', { headers: apiHeaders('token') }) + }) +}) diff --git a/src/api/Api.ts b/src/api/Api.ts index b2fc54df..57d47342 100644 --- a/src/api/Api.ts +++ b/src/api/Api.ts @@ -4,11 +4,46 @@ type StatusSuccess = 'success' type StatusError = 'error' type Status = StatusSuccess | StatusError -export interface Response { - data: T +export interface ApiResponse { + data?: T status: Status error?: { code: number message: string } } + +export async function apiClient(url: string, options: RequestInit = {}): Promise> { + try { + const response = await fetch(url, options) + + if (!response.ok) { + const message = await response.text() + throw new Error(`HTTP error! status: ${response.status}, message: ${message}`) + } + + const data: ApiResponse = await response.json() + + if (data.status === 'error') { + throw new Error(data.error?.message || 'Unknown error') + } + + return data + } catch (error) { + return { + status: 'error', + error: { + code: 500, + message: (error as Error).message || 'Unknown error', + }, + } + } +} + +export function apiHeaders(token: string): HeadersInit { + return { + Accept: 'application/json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + } +} diff --git a/src/api/Auth.test.ts b/src/api/Auth.test.ts index 49f1b3ef..1f9d12dd 100644 --- a/src/api/Auth.test.ts +++ b/src/api/Auth.test.ts @@ -1,13 +1,12 @@ import { login } from './Auth' -describe('Auth', function () { +describe('Auth', () => { beforeEach(() => { - fetch.resetMocks() - fetch.doMock() + fetchMock.resetMocks() }) - test('login failed', function () { - fetch.mockResponse( + it('should fail to login when credentials wrong', () => { + fetchMock.mockResponse( JSON.stringify({ data: {}, status: 'error', @@ -19,19 +18,19 @@ describe('Auth', function () { expect(login('user@systemli.org', 'password')).rejects.toThrow('Login failed') }) - test('server error', function () { - fetch.mockReject() + it('should fail when network fails', () => { + fetchMock.mockReject() expect(login('user@systemli.org', 'password')).rejects.toThrow('Login failed') }) - test('login successful', function () { + it('should succeed', () => { const response = { code: 200, expire: '2022-10-01T18:22:37+02:00', token: 'token', } - fetch.mockResponse(JSON.stringify(response), { status: 200 }) + fetchMock.mockResponse(JSON.stringify(response), { status: 200 }) expect(login('user@systemli.org', 'password')).resolves.toEqual(response) }) diff --git a/src/api/Features.test.ts b/src/api/Features.test.ts new file mode 100644 index 00000000..65b9a26e --- /dev/null +++ b/src/api/Features.test.ts @@ -0,0 +1,34 @@ +import { ApiUrl, apiHeaders } from './Api' +import { fetchFeaturesApi } from './Features' + +describe('fetchFeaturesApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { features: { telegramEnabled: true } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchFeaturesApi('token') + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/features`, { headers: apiHeaders('token') }) + }) + + it('should return data on error', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchFeaturesApi('token') + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/features`, { headers: apiHeaders('token') }) + }) + + it('should return data on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await fetchFeaturesApi('token') + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/features`, { headers: apiHeaders('token') }) + }) +}) diff --git a/src/api/Features.ts b/src/api/Features.ts index 395c5ba5..e2159e06 100644 --- a/src/api/Features.ts +++ b/src/api/Features.ts @@ -1,4 +1,4 @@ -import { ApiUrl, Response } from './Api' +import { ApiResponse, ApiUrl, apiClient, apiHeaders } from './Api' interface FeaturesResponseData { features: Features @@ -8,18 +8,6 @@ export interface Features { telegramEnabled: boolean } -export function useFeatureApi(token: string) { - const headers = { - Accept: 'application/json', - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - } - - const getFeatures = (): Promise> => { - return fetch(`${ApiUrl}/admin/features`, { - headers: headers, - }).then(response => response.json()) - } - - return { getFeatures } +export async function fetchFeaturesApi(token: string): Promise> { + return apiClient(`${ApiUrl}/admin/features`, { headers: apiHeaders(token) }) } diff --git a/src/api/Message.test.ts b/src/api/Message.test.ts new file mode 100644 index 00000000..273de3b1 --- /dev/null +++ b/src/api/Message.test.ts @@ -0,0 +1,167 @@ +import { ApiUrl, apiHeaders } from './Api' +import { Message, deleteMessageApi, fetchMessagesApi, postMessageApi } from './Message' + +describe('fetchMessagesApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { messages: [] } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchMessagesApi('token', 1) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/messages?limit=10`, { headers: apiHeaders('token') }) + }) + + it('should return data with before parameter', async () => { + const data = { status: 'success', data: { messages: [] } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchMessagesApi('token', 1, 2) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/messages?limit=10&before=2`, { headers: apiHeaders('token') }) + }) + + it('should return data with after parameter', async () => { + const data = { status: 'success', data: { messages: [] } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchMessagesApi('token', 1, undefined, 3) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/messages?limit=10&after=3`, { headers: apiHeaders('token') }) + }) + + it('should return data with before and after parameters', async () => { + const data = { status: 'success', data: { messages: [] } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchMessagesApi('token', 1, 2, 3) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/messages?limit=10&before=2&after=3`, { headers: apiHeaders('token') }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchMessagesApi('token', 1) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/messages?limit=10`, { headers: apiHeaders('token') }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await fetchMessagesApi('token', 1) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/messages?limit=10`, { headers: apiHeaders('token') }) + }) + + it('should return data with custom limit', async () => { + const data = { status: 'success', data: { messages: [] } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchMessagesApi('token', 1, undefined, undefined, 5) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/messages?limit=5`, { headers: apiHeaders('token') }) + }) +}) + +describe('postMessageApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { message: {} } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await postMessageApi('token', 'ticker', 'text', { type: 'FeatureCollection', features: [] }, []) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/ticker/messages`, { + headers: apiHeaders('token'), + method: 'post', + body: JSON.stringify({ + text: 'text', + geoInformation: { type: 'FeatureCollection', features: [] }, + attachments: [], + }), + }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await postMessageApi('token', 'ticker', 'text', { type: 'FeatureCollection', features: [] }, []) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/ticker/messages`, { + headers: apiHeaders('token'), + method: 'post', + body: JSON.stringify({ + text: 'text', + geoInformation: { type: 'FeatureCollection', features: [] }, + attachments: [], + }), + }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await postMessageApi('token', 'ticker', 'text', { type: 'FeatureCollection', features: [] }, []) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/ticker/messages`, { + headers: apiHeaders('token'), + method: 'post', + body: JSON.stringify({ + text: 'text', + geoInformation: { type: 'FeatureCollection', features: [] }, + attachments: [], + }), + }) + }) +}) + +describe('deleteMessageApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success' } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await deleteMessageApi('token', { id: 1, ticker: 1 } as Message) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/messages/1`, { + headers: apiHeaders('token'), + method: 'delete', + }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await deleteMessageApi('token', { id: 1, ticker: 1 } as Message) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/messages/1`, { + headers: apiHeaders('token'), + method: 'delete', + }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await deleteMessageApi('token', { id: 1, ticker: 1 } as Message) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/messages/1`, { + headers: apiHeaders('token'), + method: 'delete', + }) + }) +}) diff --git a/src/api/Message.ts b/src/api/Message.ts index bb4ac36e..76dfac46 100644 --- a/src/api/Message.ts +++ b/src/api/Message.ts @@ -1,5 +1,5 @@ -import { ApiUrl, Response } from './Api' import { FeatureCollection, Geometry } from 'geojson' +import { ApiResponse, ApiUrl, apiClient, apiHeaders } from './Api' interface MessagesResponseData { messages: Array @@ -26,53 +26,44 @@ export interface Attachment { contentType: string } -export function useMessageApi(token: string) { - const headers = { - Accept: 'application/json', - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - } - - const getMessages = (ticker: number, before?: number, after?: number, limit = 10): Promise> => { - let params = `limit=${limit}` - - if (before) { - params += `&before=${before}` - } - - if (after) { - params += `&after=${after}` - } +export async function fetchMessagesApi(token: string, ticker: number, before?: number, after?: number, limit = 10): Promise> { + let params = `limit=${limit}` - return fetch(`${ApiUrl}/admin/tickers/${ticker}/messages?${params}`, { - headers: headers, - }).then(response => response.json()) + if (before) { + params += `&before=${before}` } - const postMessage = ( - ticker: string, - text: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - geoInformation: FeatureCollection, - attachments: number[] - ): Promise> => { - return fetch(`${ApiUrl}/admin/tickers/${ticker}/messages`, { - headers: headers, - body: JSON.stringify({ - text: text, - geoInformation: geoInformation, - attachments: attachments, - }), - method: 'post', - }).then(response => response.json()) + if (after) { + params += `&after=${after}` } - const deleteMessage = (message: Message): Promise> => { - return fetch(`${ApiUrl}/admin/tickers/${message.ticker}/messages/${message.id}`, { - headers: headers, - method: 'delete', - }).then(response => response.json()) - } + return apiClient(`${ApiUrl}/admin/tickers/${ticker}/messages?${params}`, { + headers: apiHeaders(token), + }) +} + +export async function postMessageApi( + token: string, + ticker: string, + text: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + geoInformation: FeatureCollection, + attachments: number[] +): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${ticker}/messages`, { + headers: apiHeaders(token), + method: 'post', + body: JSON.stringify({ + text: text, + geoInformation: geoInformation, + attachments: attachments, + }), + }) +} - return { deleteMessage, getMessages, postMessage } +export async function deleteMessageApi(token: string, message: Message): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${message.ticker}/messages/${message.id}`, { + headers: apiHeaders(token), + method: 'delete', + }) } diff --git a/src/api/Settings.test.ts b/src/api/Settings.test.ts new file mode 100644 index 00000000..9647e2f7 --- /dev/null +++ b/src/api/Settings.test.ts @@ -0,0 +1,250 @@ +import { ApiUrl, apiHeaders } from './Api' +import { fetchInactiveSettingsApi, fetchRefreshIntervalApi, putInactiveSettingsApi, putRefreshIntervalApi } from './Settings' + +describe('fetchInactiveSettingsApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { + status: 'success', + data: { + setting: { + id: 1, + name: 'inactive_settings', + value: { + headline: 'headline', + subHeadline: 'subHeadline', + description: 'description', + author: 'author', + email: 'email', + homepage: 'homepage', + twitter: 'twitter', + }, + }, + }, + } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchInactiveSettingsApi('token') + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/settings/inactive_settings`, { headers: apiHeaders('token') }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchInactiveSettingsApi('token') + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/settings/inactive_settings`, { headers: apiHeaders('token') }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await fetchInactiveSettingsApi('token') + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/settings/inactive_settings`, { headers: apiHeaders('token') }) + }) +}) + +describe('fetchRefreshIntervalApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { + status: 'success', + data: { + setting: { + id: 1, + name: 'refresh_interval', + value: { + refreshInterval: '10', + }, + }, + }, + } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchRefreshIntervalApi('token') + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/settings/refresh_interval`, { headers: apiHeaders('token') }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchRefreshIntervalApi('token') + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/settings/refresh_interval`, { headers: apiHeaders('token') }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await fetchRefreshIntervalApi('token') + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/settings/refresh_interval`, { headers: apiHeaders('token') }) + }) +}) + +describe('putRefreshIntervalApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { + status: 'success', + data: { + setting: { + id: 1, + name: 'refresh_interval', + value: { + refreshInterval: '10', + }, + }, + }, + } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putRefreshIntervalApi('token', 10) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/settings/refresh_interval`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ refreshInterval: 10 }), + }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putRefreshIntervalApi('token', 10) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/settings/refresh_interval`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ refreshInterval: 10 }), + }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await putRefreshIntervalApi('token', 10) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/settings/refresh_interval`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ refreshInterval: 10 }), + }) + }) +}) + +describe('putInactiveSettingsApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { + status: 'success', + data: { + setting: { + id: 1, + name: 'inactive_settings', + value: { + headline: 'headline', + subHeadline: 'subHeadline', + description: 'description', + author: 'author', + email: 'email', + homepage: 'homepage', + twitter: 'twitter', + }, + }, + }, + } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putInactiveSettingsApi('token', { + headline: 'headline', + subHeadline: 'subHeadline', + description: 'description', + author: 'author', + email: 'email', + homepage: 'homepage', + twitter: 'twitter', + }) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/settings/inactive_settings`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify(data.data.setting.value), + }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putInactiveSettingsApi('token', { + headline: 'headline', + subHeadline: 'subHeadline', + description: 'description', + author: 'author', + email: 'email', + homepage: 'homepage', + twitter: 'twitter', + }) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/settings/inactive_settings`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ + headline: 'headline', + subHeadline: 'subHeadline', + description: 'description', + author: 'author', + email: 'email', + homepage: 'homepage', + twitter: 'twitter', + }), + }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await putInactiveSettingsApi('token', { + headline: 'headline', + subHeadline: 'subHeadline', + description: 'description', + author: 'author', + email: 'email', + homepage: 'homepage', + twitter: 'twitter', + }) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/settings/inactive_settings`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ + headline: 'headline', + subHeadline: 'subHeadline', + description: 'description', + author: 'author', + email: 'email', + homepage: 'homepage', + twitter: 'twitter', + }), + }) + }) +}) diff --git a/src/api/Settings.ts b/src/api/Settings.ts index e42be377..24dab88b 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -1,4 +1,4 @@ -import { ApiUrl, Response } from './Api' +import { ApiResponse, ApiUrl, apiClient, apiHeaders } from './Api' interface InactiveSettingsResponseData { setting: Setting @@ -34,45 +34,26 @@ export interface RefreshInterval { value: string } -export function useSettingsApi(token: string) { - const headers = { - Accept: 'application/json', - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - } - - const getInactiveSettings = (): Promise> => { - return fetch(`${ApiUrl}/admin/settings/inactive_settings`, { - headers: headers, - }).then(response => response.json()) - } - - const getRefreshInterval = (): Promise> => { - return fetch(`${ApiUrl}/admin/settings/refresh_interval`, { - headers: headers, - }).then(response => response.json()) - } +export async function fetchInactiveSettingsApi(token: string): Promise> { + return apiClient(`${ApiUrl}/admin/settings/inactive_settings`, { headers: apiHeaders(token) }) +} - const putRefreshInterval = (refreshInterval: number): Promise> => { - return fetch(`${ApiUrl}/admin/settings/refresh_interval`, { - headers: headers, - body: JSON.stringify({ refreshInterval: refreshInterval }), - method: 'put', - }).then(response => response.json()) - } +export async function fetchRefreshIntervalApi(token: string): Promise> { + return apiClient(`${ApiUrl}/admin/settings/refresh_interval`, { headers: apiHeaders(token) }) +} - const putInactiveSettings = (data: InactiveSetting): Promise> => { - return fetch(`${ApiUrl}/admin/settings/inactive_settings`, { - headers: headers, - body: JSON.stringify(data), - method: 'put', - }).then(response => response.json()) - } +export async function putRefreshIntervalApi(token: string, refreshInterval: number): Promise> { + return apiClient(`${ApiUrl}/admin/settings/refresh_interval`, { + headers: apiHeaders(token), + method: 'put', + body: JSON.stringify({ refreshInterval: refreshInterval }), + }) +} - return { - getInactiveSettings, - getRefreshInterval, - putInactiveSettings, - putRefreshInterval, - } +export async function putInactiveSettingsApi(token: string, data: InactiveSetting): Promise> { + return apiClient(`${ApiUrl}/admin/settings/inactive_settings`, { + headers: apiHeaders(token), + method: 'put', + body: JSON.stringify(data), + }) } diff --git a/src/api/Ticker.test.ts b/src/api/Ticker.test.ts new file mode 100644 index 00000000..cc9c3a12 --- /dev/null +++ b/src/api/Ticker.test.ts @@ -0,0 +1,576 @@ +import { ApiUrl, apiHeaders } from './Api' +import { + Ticker, + TickerBlueskyFormData, + TickerFormData, + TickerMastodonFormData, + TickerTelegramFormData, + deleteTickerApi, + deleteTickerBlueskyApi, + deleteTickerMastodonApi, + deleteTickerTelegramApi, + deleteTickerUserApi, + fetchTickerApi, + fetchTickerUsersApi, + fetchTickersApi, + postTickerApi, + putTickerApi, + putTickerBlueskyApi, + putTickerMastodonApi, + putTickerResetApi, + putTickerTelegramApi, + putTickerUsersApi, +} from './Ticker' +import { User } from './User' + +describe('fetchTickersApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { tickers: [] } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchTickersApi('token', { active: true }) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers?active=true`, { headers: apiHeaders('token') }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchTickersApi('token', {}) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers?`, { headers: apiHeaders('token') }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await fetchTickersApi('token', {}) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers?`, { headers: apiHeaders('token') }) + }) +}) + +describe('fetchTickerApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { ticker: { id: 1 } } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchTickerApi('token', 1) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1`, { headers: apiHeaders('token') }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchTickerApi('token', 1) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1`, { headers: apiHeaders('token') }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await fetchTickerApi('token', 1) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1`, { headers: apiHeaders('token') }) + }) +}) + +describe('deleteTickerApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success' } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await deleteTickerApi('token', { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1`, { headers: apiHeaders('token'), method: 'delete' }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await deleteTickerApi('token', { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1`, { headers: apiHeaders('token'), method: 'delete' }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await deleteTickerApi('token', { id: 1 } as Ticker) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1`, { headers: apiHeaders('token'), method: 'delete' }) + }) +}) + +describe('postTickerApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { ticker: { id: 1 } } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await postTickerApi('token', { title: 'title' } as TickerFormData) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers`, { + headers: apiHeaders('token'), + method: 'post', + body: JSON.stringify({ title: 'title' }), + }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await postTickerApi('token', { title: 'title' } as TickerFormData) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers`, { + headers: apiHeaders('token'), + method: 'post', + body: JSON.stringify({ title: 'title' }), + }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await postTickerApi('token', { title: 'title' } as TickerFormData) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers`, { + headers: apiHeaders('token'), + method: 'post', + body: JSON.stringify({ title: 'title' }), + }) + }) +}) + +describe('putTickerApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { ticker: { id: 1 } } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putTickerApi('token', { title: 'title' } as TickerFormData, 1) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ title: 'title' }), + }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putTickerApi('token', { title: 'title' } as TickerFormData, 1) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ title: 'title' }), + }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await putTickerApi('token', { title: 'title' } as TickerFormData, 1) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ title: 'title' }), + }) + }) +}) + +describe('fetchTickerUsersApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { users: [] } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchTickerUsersApi('token', { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/users`, { headers: apiHeaders('token') }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchTickerUsersApi('token', { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/users`, { headers: apiHeaders('token') }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await fetchTickerUsersApi('token', { id: 1 } as Ticker) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/users`, { headers: apiHeaders('token') }) + }) +}) + +describe('deleteTickerUserApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success' } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await deleteTickerUserApi('token', { id: 1 } as Ticker, { id: 1 } as User) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/users/1`, { headers: apiHeaders('token'), method: 'delete' }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await deleteTickerUserApi('token', { id: 1 } as Ticker, { id: 1 } as User) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/users/1`, { headers: apiHeaders('token'), method: 'delete' }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await deleteTickerUserApi('token', { id: 1 } as Ticker, { id: 1 } as User) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/users/1`, { headers: apiHeaders('token'), method: 'delete' }) + }) +}) + +describe('putTickerUsersApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { users: [] } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putTickerUsersApi('token', { id: 1 } as Ticker, [{ id: 1 } as User]) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/users`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ users: [{ id: 1 }] }), + }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putTickerUsersApi('token', { id: 1 } as Ticker, [{ id: 1 } as User]) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/users`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ users: [{ id: 1 }] }), + }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await putTickerUsersApi('token', { id: 1 } as Ticker, [{ id: 1 } as User]) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/users`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ users: [{ id: 1 }] }), + }) + }) +}) + +describe('putTickerResetApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { ticker: { id: 1 } } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putTickerResetApi('token', { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/reset`, { headers: apiHeaders('token'), method: 'put' }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putTickerResetApi('token', { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/reset`, { headers: apiHeaders('token'), method: 'put' }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await putTickerResetApi('token', { id: 1 } as Ticker) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/reset`, { headers: apiHeaders('token'), method: 'put' }) + }) +}) + +describe('putTickerMastodonApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { ticker: { id: 1 } } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putTickerMastodonApi('token', { active: true } as TickerMastodonFormData, { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/mastodon`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ active: true }), + }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putTickerMastodonApi('token', {} as TickerMastodonFormData, { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/mastodon`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({}), + }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await putTickerMastodonApi('token', {} as TickerMastodonFormData, { id: 1 } as Ticker) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/mastodon`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({}), + }) + }) +}) + +describe('deleteTickerMastodonApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { ticker: { id: 1 } } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await deleteTickerMastodonApi('token', { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/mastodon`, { headers: apiHeaders('token'), method: 'delete' }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await deleteTickerMastodonApi('token', { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/mastodon`, { headers: apiHeaders('token'), method: 'delete' }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await deleteTickerMastodonApi('token', { id: 1 } as Ticker) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/mastodon`, { headers: apiHeaders('token'), method: 'delete' }) + }) +}) + +describe('putTickerTelegramApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { ticker: { id: 1 } } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putTickerTelegramApi('token', { active: true } as TickerTelegramFormData, { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/telegram`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ active: true }), + }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putTickerTelegramApi('token', {} as TickerTelegramFormData, { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/telegram`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({}), + }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await putTickerTelegramApi('token', {} as TickerTelegramFormData, { id: 1 } as Ticker) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/telegram`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({}), + }) + }) +}) + +describe('deleteTickerTelegramApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { ticker: { id: 1 } } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await deleteTickerTelegramApi('token', { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/telegram`, { headers: apiHeaders('token'), method: 'delete' }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await deleteTickerTelegramApi('token', { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/telegram`, { headers: apiHeaders('token'), method: 'delete' }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await deleteTickerTelegramApi('token', { id: 1 } as Ticker) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/telegram`, { headers: apiHeaders('token'), method: 'delete' }) + }) +}) + +describe('putTickerBlueskyApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { ticker: { id: 1 } } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putTickerBlueskyApi('token', { active: true } as TickerBlueskyFormData, { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/bluesky`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ active: true }), + }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putTickerBlueskyApi('token', {} as TickerBlueskyFormData, { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/bluesky`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({}), + }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await putTickerBlueskyApi('token', {} as TickerBlueskyFormData, { id: 1 } as Ticker) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/bluesky`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({}), + }) + }) +}) + +describe('deleteTickerBlueskyApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { ticker: { id: 1 } } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await deleteTickerBlueskyApi('token', { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/bluesky`, { headers: apiHeaders('token'), method: 'delete' }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await deleteTickerBlueskyApi('token', { id: 1 } as Ticker) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/bluesky`, { headers: apiHeaders('token'), method: 'delete' }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await deleteTickerBlueskyApi('token', { id: 1 } as Ticker) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/tickers/1/bluesky`, { headers: apiHeaders('token'), method: 'delete' }) + }) +}) diff --git a/src/api/Ticker.ts b/src/api/Ticker.ts index f7baca42..eaee3210 100644 --- a/src/api/Ticker.ts +++ b/src/api/Ticker.ts @@ -1,5 +1,5 @@ import { SortDirection } from '@mui/material' -import { ApiUrl, Response } from './Api' +import { ApiResponse, ApiUrl, apiClient, apiHeaders } from './Api' import { User } from './User' interface TickersResponseData { @@ -14,6 +14,18 @@ interface TickerUsersResponseData { users: Array } +export interface TickerFormData { + domain: string + title: string + description: string + active: boolean + information: TickerInformation + mastodon: TickerMastodon + telegram: TickerTelegram + bluesky: TickerBluesky + location: TickerLocation +} + export interface Ticker { id: number createdAt: Date @@ -95,140 +107,86 @@ export interface GetTickersQueryParams { sort?: SortDirection } -export function useTickerApi(token: string) { - const headers = { - Accept: 'application/json', - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - } +export async function fetchTickersApi(token: string, params: GetTickersQueryParams): Promise> { + const query = new URLSearchParams() - const deleteTicker = (ticker: Ticker): Promise> => { - return fetch(`${ApiUrl}/admin/tickers/${ticker.id}`, { - headers: headers, - method: 'delete', - }).then(response => response.json()) - } - - const getTickerUsers = (ticker: Ticker): Promise> => { - return fetch(`${ApiUrl}/admin/tickers/${ticker.id}/users`, { - headers: headers, - }).then(response => response.json()) - } - - const deleteTickerUser = (ticker: Ticker, user: User): Promise> => { - return fetch(`${ApiUrl}/admin/tickers/${ticker.id}/users/${user.id}`, { - headers: headers, - method: 'delete', - }).then(response => response.json()) + for (const [key, value] of Object.entries(params)) { + if (value !== null && value !== undefined) { + query.append(key, String(value)) + } } - const getTickers = (params: GetTickersQueryParams): Promise> => { - const query = new URLSearchParams() + return apiClient(`${ApiUrl}/admin/tickers?${query}`, { headers: apiHeaders(token) }) +} - for (const [key, value] of Object.entries(params)) { - if (value !== null && value !== undefined) { - query.append(key, String(value)) - } - } +export async function fetchTickerApi(token: string, id: number): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${id}`, { headers: apiHeaders(token) }) +} - return fetch(`${ApiUrl}/admin/tickers?${query}`, { headers: headers }).then(response => response.json()) - } +export async function deleteTickerApi(token: string, ticker: Ticker): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${ticker.id}`, { headers: apiHeaders(token), method: 'delete' }) +} - const getTicker = (id: number): Promise> => { - return fetch(`${ApiUrl}/admin/tickers/${id}`, { headers: headers }).then(response => response.json()) - } +export async function postTickerApi(token: string, data: TickerFormData): Promise> { + return apiClient(`${ApiUrl}/admin/tickers`, { headers: apiHeaders(token), method: 'post', body: JSON.stringify(data) }) +} - const postTicker = (data: Ticker): Promise> => { - return fetch(`${ApiUrl}/admin/tickers`, { - headers: headers, - body: JSON.stringify(data), - method: 'post', - }).then(response => response.json()) - } +export async function putTickerApi(token: string, data: TickerFormData, id: number): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${id}`, { headers: apiHeaders(token), method: 'put', body: JSON.stringify(data) }) +} - const putTicker = (data: Ticker, id: number): Promise> => { - return fetch(`${ApiUrl}/admin/tickers/${id}`, { - headers: headers, - body: JSON.stringify(data), - method: 'put', - }).then(response => response.json()) - } +export async function fetchTickerUsersApi(token: string, ticker: Ticker): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${ticker.id}/users`, { headers: apiHeaders(token) }) +} - const putTickerUsers = (ticker: Ticker, users: User[]): Promise> => { - return fetch(`${ApiUrl}/admin/tickers/${ticker.id}/users`, { - headers: headers, - method: 'put', - body: JSON.stringify({ users: users }), - }).then(response => response.json()) - } +export async function deleteTickerUserApi(token: string, ticker: Ticker, user: User): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${ticker.id}/users/${user.id}`, { headers: apiHeaders(token), method: 'delete' }) +} - const putTickerReset = (ticker: Ticker): Promise> => { - return fetch(`${ApiUrl}/admin/tickers/${ticker.id}/reset`, { - headers: headers, - method: 'put', - }).then(response => response.json()) - } +export async function putTickerUsersApi(token: string, ticker: Ticker, users: User[]): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${ticker.id}/users`, { + headers: apiHeaders(token), + method: 'put', + body: JSON.stringify({ users: users }), + }) +} - const putTickerMastodon = (data: TickerMastodonFormData, ticker: Ticker): Promise> => { - return fetch(`${ApiUrl}/admin/tickers/${ticker.id}/mastodon`, { - headers: headers, - body: JSON.stringify(data), - method: 'put', - }).then(response => response.json()) - } +export async function putTickerResetApi(token: string, ticker: Ticker): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${ticker.id}/reset`, { headers: apiHeaders(token), method: 'put' }) +} - const deleteTickerMastodon = (ticker: Ticker): Promise> => { - return fetch(`${ApiUrl}/admin/tickers/${ticker.id}/mastodon`, { - headers: headers, - method: 'delete', - }).then(response => response.json()) - } +export async function putTickerMastodonApi(token: string, data: TickerMastodonFormData, ticker: Ticker): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${ticker.id}/mastodon`, { + headers: apiHeaders(token), + method: 'put', + body: JSON.stringify(data), + }) +} - const putTickerTelegram = (data: TickerTelegramFormData, ticker: Ticker): Promise> => { - return fetch(`${ApiUrl}/admin/tickers/${ticker.id}/telegram`, { - headers: headers, - body: JSON.stringify(data), - method: 'put', - }).then(response => response.json()) - } +export async function deleteTickerMastodonApi(token: string, ticker: Ticker): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${ticker.id}/mastodon`, { headers: apiHeaders(token), method: 'delete' }) +} - const deleteTickerTelegram = (ticker: Ticker): Promise> => { - return fetch(`${ApiUrl}/admin/tickers/${ticker.id}/telegram`, { - headers: headers, - method: 'delete', - }).then(response => response.json()) - } +export async function putTickerTelegramApi(token: string, data: TickerTelegramFormData, ticker: Ticker): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${ticker.id}/telegram`, { + headers: apiHeaders(token), + method: 'put', + body: JSON.stringify(data), + }) +} - const putTickerBluesky = (data: TickerBlueskyFormData, ticker: Ticker): Promise> => { - return fetch(`${ApiUrl}/admin/tickers/${ticker.id}/bluesky`, { - headers: headers, - body: JSON.stringify(data), - method: 'put', - }).then(response => response.json()) - } +export async function deleteTickerTelegramApi(token: string, ticker: Ticker): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${ticker.id}/telegram`, { headers: apiHeaders(token), method: 'delete' }) +} - const deleteTickerBluesky = (ticker: Ticker): Promise> => { - return fetch(`${ApiUrl}/admin/tickers/${ticker.id}/bluesky`, { - headers: headers, - method: 'delete', - }).then(response => response.json()) - } +export async function putTickerBlueskyApi(token: string, data: TickerBlueskyFormData, ticker: Ticker): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${ticker.id}/bluesky`, { + headers: apiHeaders(token), + method: 'put', + body: JSON.stringify(data), + }) +} - return { - deleteTicker, - deleteTickerUser, - getTickers, - getTicker, - getTickerUsers, - postTicker, - putTicker, - putTickerUsers, - putTickerReset, - putTickerMastodon, - deleteTickerMastodon, - putTickerTelegram, - deleteTickerTelegram, - putTickerBluesky, - deleteTickerBluesky, - } +export async function deleteTickerBlueskyApi(token: string, ticker: Ticker): Promise> { + return apiClient(`${ApiUrl}/admin/tickers/${ticker.id}/bluesky`, { headers: apiHeaders(token), method: 'delete' }) } diff --git a/src/api/Upload.test.ts b/src/api/Upload.test.ts new file mode 100644 index 00000000..70a1d258 --- /dev/null +++ b/src/api/Upload.test.ts @@ -0,0 +1,34 @@ +import { ApiUrl, apiHeaders } from './Api' +import { postUploadApi } from './Upload' + +describe('postUploadApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { uploads: [] } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await postUploadApi('token', new FormData()) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/upload`, { headers: apiHeaders('token'), method: 'post', body: new FormData() }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await postUploadApi('token', new FormData()) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/upload`, { headers: apiHeaders('token'), method: 'post', body: new FormData() }) + }) + + it('should throw error on network failure', async () => { + fetchMock.mockReject(new Error('Failed to fetch')) + const response = await postUploadApi('token', new FormData()) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Failed to fetch' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/upload`, { headers: apiHeaders('token'), method: 'post', body: new FormData() }) + }) +}) diff --git a/src/api/Upload.ts b/src/api/Upload.ts index 319ac20c..0dd678eb 100644 --- a/src/api/Upload.ts +++ b/src/api/Upload.ts @@ -1,4 +1,4 @@ -import { ApiUrl, Response } from './Api' +import { ApiResponse, ApiUrl, apiClient, apiHeaders } from './Api' interface UploadeResponseData { uploads: Array @@ -12,19 +12,6 @@ export interface Upload { content_type: string } -export function useUploadApi(token: string) { - const headers = { - Accept: 'application/json', - Authorization: `Bearer ${token}`, - } - - const postUpload = (formData: FormData): Promise> => { - return fetch(`${ApiUrl}/admin/upload`, { - headers: headers, - body: formData, - method: 'post', - }).then(response => response.json()) - } - - return { postUpload } +export async function postUploadApi(token: string, formData: FormData): Promise> { + return apiClient(`${ApiUrl}/admin/upload`, { headers: apiHeaders(token), method: 'post', body: formData }) } diff --git a/src/api/User.test.ts b/src/api/User.test.ts new file mode 100644 index 00000000..66be6d4d --- /dev/null +++ b/src/api/User.test.ts @@ -0,0 +1,212 @@ +import { ApiUrl, apiHeaders } from './Api' +import { deleteUserApi, fetchUsersApi, putMeApi, putUserApi } from './User' + +describe('fetchUsersApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { users: [] } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchUsersApi('token') + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/users`, { headers: apiHeaders('token') }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await fetchUsersApi('token') + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/users`, { headers: apiHeaders('token') }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await fetchUsersApi('token') + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/users`, { headers: apiHeaders('token') }) + }) +}) + +describe('putUserApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const user = { id: 1, createdAt: new Date(), email: 'user@example.com', role: 'user', isSuperAdmin: false } + const data = { status: 'success', data: { user: user } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + await putUserApi('token', user, { email: 'user@example.com', isSuperAdmin: false }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/users/1`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ email: 'user@example.com', isSuperAdmin: false }), + }) + }) + + it('should throw error on non-200 status', async () => { + const user = { id: 1, createdAt: new Date(), email: 'user@example.com', role: 'user', isSuperAdmin: false } + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putUserApi('token', user, { email: 'user@example.com', isSuperAdmin: false }) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/users/1`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ email: 'user@example.com', isSuperAdmin: false }), + }) + }) + + it('should throw error on network error', async () => { + const user = { id: 1, createdAt: new Date(), email: 'user@example.com', role: 'user', isSuperAdmin: false } + fetchMock.mockReject(new Error('Network error')) + const response = await putUserApi('token', user, { email: 'user@example.com', isSuperAdmin: false }) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/users/1`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ email: 'user@example.com', isSuperAdmin: false }), + }) + }) +}) + +describe('postUserApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const user = { id: 1, createdAt: new Date(), email: 'user@example.com', role: 'user', isSuperAdmin: false } + const data = { status: 'success', data: { user: user } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + putUserApi('token', user, { email: 'user@example.com', isSuperAdmin: false }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/users/1`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ email: 'user@example.com', isSuperAdmin: false }), + }) + }) + + it('should throw error on non-200 status', async () => { + const user = { id: 1, createdAt: new Date(), email: 'user@example.com', role: 'user', isSuperAdmin: false } + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putUserApi('token', user, { email: 'user@example.com', isSuperAdmin: false }) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/users/1`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ email: 'user@example.com', isSuperAdmin: false }), + }) + }) + + it('should throw error on network error', async () => { + const user = { id: 1, createdAt: new Date(), email: 'user@example.com', role: 'user', isSuperAdmin: false } + fetchMock.mockReject(new Error('Network error')) + const response = await putUserApi('token', user, { email: 'user@example.com', isSuperAdmin: false }) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/users/1`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ email: 'user@example.com', isSuperAdmin: false }), + }) + }) +}) + +describe('deleteUserApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const user = { id: 1, createdAt: new Date(), email: 'user@example.com', role: 'user', isSuperAdmin: false } + const data = { status: 'success' } + fetchMock.mockResponseOnce(JSON.stringify(data)) + await deleteUserApi('token', user) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/users/1`, { + headers: apiHeaders('token'), + method: 'delete', + }) + }) + + it('should throw error on non-200 status', async () => { + const user = { id: 1, createdAt: new Date(), email: 'user@example.com', role: 'user', isSuperAdmin: false } + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await deleteUserApi('token', user) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/users/1`, { + headers: apiHeaders('token'), + method: 'delete', + }) + }) + + it('should throw error on network error', async () => { + const user = { id: 1, createdAt: new Date(), email: 'user@example.com', role: 'user', isSuperAdmin: false } + fetchMock.mockReject(new Error('Network error')) + const response = await deleteUserApi('token', user) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/users/1`, { + headers: apiHeaders('token'), + method: 'delete', + }) + }) +}) + +describe('putMeApi', () => { + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should return data on success', async () => { + const data = { status: 'success', data: { user: { id: 1, createdAt: new Date(), email: 'user@example.com', role: 'user', isSuperAdmin: false } } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + await putMeApi('token', { password: 'password', newPassword: 'newpassword' }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/users/me`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ password: 'password', newPassword: 'newpassword' }), + }) + }) + + it('should throw error on non-200 status', async () => { + const data = { status: 'error', error: { code: 500, message: 'Internal Server Error' } } + fetchMock.mockResponseOnce(JSON.stringify(data)) + const response = await putMeApi('token', { password: 'password', newPassword: 'newpassword' }) + expect(response).toEqual(data) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/users/me`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ password: 'password', newPassword: 'newpassword' }), + }) + }) + + it('should throw error on network error', async () => { + fetchMock.mockReject(new Error('Network error')) + const response = await putMeApi('token', { password: 'password', newPassword: 'newpassword' }) + expect(response).toEqual({ status: 'error', error: { code: 500, message: 'Network error' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${ApiUrl}/admin/users/me`, { + headers: apiHeaders('token'), + method: 'put', + body: JSON.stringify({ password: 'password', newPassword: 'newpassword' }), + }) + }) +}) diff --git a/src/api/User.ts b/src/api/User.ts index 5a6766ac..d58fd7ce 100644 --- a/src/api/User.ts +++ b/src/api/User.ts @@ -1,4 +1,4 @@ -import { ApiUrl, Response } from './Api' +import { ApiResponse, ApiUrl, apiClient, apiHeaders } from './Api' import { Ticker } from './Ticker' export interface UsersResponseData { @@ -29,47 +29,37 @@ export interface MeData { newPassword: string } -export function useUserApi(token: string) { - const headers = { - Accept: 'application/json', - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - } - - const getUsers = (): Promise> => { - return fetch(`${ApiUrl}/admin/users`, { headers: headers }).then(response => response.json()) - } - - const putUser = (data: UserData, user: User): Promise> => { - return fetch(`${ApiUrl}/admin/users/${user.id}`, { - body: JSON.stringify(data), - method: 'put', - headers: { ...headers, 'Content-Type': 'application/json' }, - }).then(response => response.json()) - } +export async function fetchUsersApi(token: string): Promise> { + return apiClient(`${ApiUrl}/admin/users`, { headers: apiHeaders(token) }) +} - const postUser = (data: UserData): Promise> => { - return fetch(`${ApiUrl}/admin/users`, { - body: JSON.stringify(data), - headers: headers, - method: 'post', - }).then(response => response.json()) - } +export async function putUserApi(token: string, user: User, data: UserData): Promise> { + return apiClient(`${ApiUrl}/admin/users/${user.id}`, { + headers: apiHeaders(token), + method: 'put', + body: JSON.stringify(data), + }) +} - const deleteUser = (user: User): Promise> => { - return fetch(`${ApiUrl}/admin/users/${user.id}`, { - headers: headers, - method: 'delete', - }).then(response => response.json()) - } +export async function postUserApi(token: string, data: UserData): Promise> { + return apiClient(`${ApiUrl}/admin/users`, { + headers: apiHeaders(token), + method: 'post', + body: JSON.stringify(data), + }) +} - const putMe = (data: MeData): Promise> => { - return fetch(`${ApiUrl}/admin/users/me`, { - body: JSON.stringify(data), - headers: headers, - method: 'put', - }).then(response => response.json()) - } +export async function deleteUserApi(token: string, user: User): Promise> { + return apiClient(`${ApiUrl}/admin/users/${user.id}`, { + headers: apiHeaders(token), + method: 'delete', + }) +} - return { getUsers, putUser, postUser, deleteUser, putMe } +export async function putMeApi(token: string, data: MeData): Promise> { + return apiClient(`${ApiUrl}/admin/users/me`, { + headers: apiHeaders(token), + method: 'put', + body: JSON.stringify(data), + }) } diff --git a/src/components/message/MessageForm.test.tsx b/src/components/message/MessageForm.test.tsx new file mode 100644 index 00000000..e6b8debf --- /dev/null +++ b/src/components/message/MessageForm.test.tsx @@ -0,0 +1,57 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router' +import * as api from '../../api/Message' +import { Ticker } from '../../api/Ticker' +import { AuthProvider } from '../../contexts/AuthContext' +import MessageForm from './MessageForm' + +describe('MessageForm', () => { + function setup(ticker: Ticker) { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return render( + + + + + + + + ) + } + + beforeEach(() => { + fetchMock.resetMocks() + }) + + it('should render the component', async () => { + vi.spyOn(api, 'postMessageApi').mockResolvedValue({ status: 'success' }) + setup({ + id: 1, + title: 'ticker', + bluesky: { active: false }, + mastodon: { active: false }, + telegram: { active: false }, + location: { lat: 0, lon: 0 }, + } as Ticker) + + expect(screen.getByText('0/4096')).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Send' })) + expect(screen.getByText('The message is required.')).toBeInTheDocument() + + await userEvent.type(screen.getByRole('textbox'), 'Hello, World!') + expect(screen.getByText('13/4096')).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Send' })) + expect(api.postMessageApi).toHaveBeenCalledTimes(1) + expect(api.postMessageApi).toHaveBeenCalledWith('', '1', 'Hello, World!', { features: [], type: 'FeatureCollection' }, []) + }) +}) diff --git a/src/components/message/MessageForm.tsx b/src/components/message/MessageForm.tsx index f1937f05..761a08c9 100644 --- a/src/components/message/MessageForm.tsx +++ b/src/components/message/MessageForm.tsx @@ -1,21 +1,21 @@ +import { faMapLocationDot, faPaperPlane } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { Box, Button, FormGroup, IconButton, Stack, TextField } from '@mui/material' +import { useQueryClient } from '@tanstack/react-query' +import { FeatureCollection, Geometry } from 'geojson' import { FC, useCallback, useEffect, useState } from 'react' -import { useMessageApi } from '../../api/Message' -import { Ticker } from '../../api/Ticker' import { SubmitHandler, useForm } from 'react-hook-form' -import { useQueryClient } from '@tanstack/react-query' -import MessageFormCounter from './MessageFormCounter' -import useAuth from '../../contexts/useAuth' +import { postMessageApi } from '../../api/Message' +import { Ticker } from '../../api/Ticker' import { Upload } from '../../api/Upload' -import UploadButton from './UploadButton' +import useAuth from '../../contexts/useAuth' +import palette from '../../theme/palette' import AttachmentsPreview from './AttachmentsPreview' +import { Emoji } from './Emoji' import EmojiPicker from './EmojiPicker' +import MessageFormCounter from './MessageFormCounter' import MessageMapModal from './MessageMapModal' -import { FeatureCollection, Geometry } from 'geojson' -import { Box, Button, FormGroup, IconButton, Stack, TextField } from '@mui/material' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faMapLocationDot, faPaperPlane } from '@fortawesome/free-solid-svg-icons' -import palette from '../../theme/palette' -import { Emoji } from './Emoji' +import UploadButton from './UploadButton' interface Props { ticker: Ticker @@ -35,7 +35,6 @@ const MessageForm: FC = ({ ticker }) => { setValue, } = useForm({ mode: 'onSubmit' }) const { token } = useAuth() - const { postMessage } = useMessageApi(token) const queryClient = useQueryClient() const [isSubmitting, setIsSubmitting] = useState(false) const [attachments, setAttachments] = useState([]) @@ -102,7 +101,7 @@ const MessageForm: FC = ({ ticker }) => { return upload.id }) - postMessage(ticker.id.toString(), data.message, map, uploads).finally(() => { + postMessageApi(token, ticker.id.toString(), data.message, map, uploads).finally(() => { queryClient.invalidateQueries({ queryKey: ['messages', ticker.id] }) setAttachments([]) setIsSubmitting(false) diff --git a/src/components/message/MessageList.tsx b/src/components/message/MessageList.tsx index d3ec23e8..c7dd97f6 100644 --- a/src/components/message/MessageList.tsx +++ b/src/components/message/MessageList.tsx @@ -1,12 +1,11 @@ +import { Button, CircularProgress } from '@mui/material' import { FC, useEffect } from 'react' -import { useInfiniteQuery } from '@tanstack/react-query' import { Ticker } from '../../api/Ticker' -import { useMessageApi } from '../../api/Message' -import Message from './Message' import useAuth from '../../contexts/useAuth' +import useMessagesQuery from '../../queries/useMessagesQuery' import ErrorView from '../../views/ErrorView' import Loader from '../Loader' -import { Button, CircularProgress } from '@mui/material' +import Message from './Message' interface Props { ticker: Ticker @@ -14,20 +13,7 @@ interface Props { const MessageList: FC = ({ ticker }) => { const { token } = useAuth() - const { getMessages } = useMessageApi(token) - - const fetchMessages = ({ pageParam = 0 }) => { - return getMessages(ticker.id, pageParam) - } - - const { data, fetchNextPage, isFetchingNextPage, hasNextPage, status } = useInfiniteQuery({ - queryKey: ['messages', ticker.id], - queryFn: fetchMessages, - initialPageParam: 0, - getNextPageParam: lastPage => { - return lastPage.data.messages.length === 10 ? lastPage.data.messages.slice(-1).pop()?.id : undefined - }, - }) + const { data, fetchNextPage, isFetchingNextPage, hasNextPage, status } = useMessagesQuery({ token, ticker }) useEffect(() => { let fetching = false @@ -63,7 +49,7 @@ const MessageList: FC = ({ ticker }) => { return ( <> - {data.pages.map(group => group.data.messages.map(message => ))} + {data.pages.map(group => group.data?.messages.map(message => ))} {isFetchingNextPage ? ( ) : hasNextPage ? ( diff --git a/src/components/message/MessageModalDelete.tsx b/src/components/message/MessageModalDelete.tsx index 4e7a963f..03462215 100644 --- a/src/components/message/MessageModalDelete.tsx +++ b/src/components/message/MessageModalDelete.tsx @@ -1,6 +1,6 @@ -import { FC, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { Message, useMessageApi } from '../../api/Message' +import { FC, useCallback } from 'react' +import { Message, deleteMessageApi } from '../../api/Message' import useAuth from '../../contexts/useAuth' import Modal from '../common/Modal' @@ -11,15 +11,14 @@ interface Props { } const MessageModalDelete: FC = ({ message, onClose, open }) => { const { token } = useAuth() - const { deleteMessage } = useMessageApi(token) const queryClient = useQueryClient() const handleDelete = useCallback(() => { - deleteMessage(message).then(() => { + deleteMessageApi(token, message).then(() => { queryClient.invalidateQueries({ queryKey: ['messages', message.ticker] }) onClose() }) - }, [deleteMessage, message, onClose, queryClient]) + }, [message, onClose, queryClient, token]) return ( diff --git a/src/components/message/UploadButton.tsx b/src/components/message/UploadButton.tsx index 81116eec..f5d23c6a 100644 --- a/src/components/message/UploadButton.tsx +++ b/src/components/message/UploadButton.tsx @@ -1,10 +1,10 @@ -import { createRef, FC, useCallback } from 'react' +import { faImages } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { IconButton } from '@mui/material' +import { createRef, FC, useCallback } from 'react' import { Ticker } from '../../api/Ticker' -import { useUploadApi, Upload } from '../../api/Upload' +import { postUploadApi, Upload } from '../../api/Upload' import useAuth from '../../contexts/useAuth' -import { faImages } from '@fortawesome/free-solid-svg-icons' import palette from '../../theme/palette' interface Props { @@ -15,7 +15,6 @@ interface Props { const UploadButton: FC = ({ onUpload, ticker }) => { const ref = createRef() const { token } = useAuth() - const { postUpload } = useUploadApi(token) const refClick = useCallback(() => { ref.current?.click() @@ -32,7 +31,10 @@ const UploadButton: FC = ({ onUpload, ticker }) => { } formData.append('ticker', ticker.id.toString()) - postUpload(formData).then(response => { + postUploadApi(token, formData).then(response => { + if (response.status === 'error' || response.data === undefined || response.data.uploads === undefined) { + return + } onUpload(response.data.uploads) }) } diff --git a/src/components/settings/InactiveSettingsCard.tsx b/src/components/settings/InactiveSettingsCard.tsx index 09a883ed..cc722142 100644 --- a/src/components/settings/InactiveSettingsCard.tsx +++ b/src/components/settings/InactiveSettingsCard.tsx @@ -1,20 +1,18 @@ +import { faPencil } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { Box, Button, Card, CardContent, Divider, Grid, Typography } from '@mui/material' +import { Stack } from '@mui/system' import { FC, useState } from 'react' -import { useQuery } from '@tanstack/react-query' -import { useSettingsApi } from '../../api/Settings' -import ErrorView from '../../views/ErrorView' import useAuth from '../../contexts/useAuth' +import useInactiveSettingsQuery from '../../queries/useInactiveSettingsQuery' +import ErrorView from '../../views/ErrorView' import Loader from '../Loader' -import { Box, Button, Card, CardContent, Divider, Grid, Typography } from '@mui/material' -import { Stack } from '@mui/system' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faPencil } from '@fortawesome/free-solid-svg-icons' import InactiveSettingsModalForm from './InactiveSettingsModalForm' const InactiveSettingsCard: FC = () => { const [formOpen, setFormOpen] = useState(false) const { token } = useAuth() - const { getInactiveSettings } = useSettingsApi(token) - const { isLoading, error, data } = useQuery({ queryKey: ['inactive_settings'], queryFn: getInactiveSettings }) + const { isLoading, error, data } = useInactiveSettingsQuery({ token }) const handleFormOpen = () => { setFormOpen(true) @@ -28,11 +26,11 @@ const InactiveSettingsCard: FC = () => { return } - if (error || data === undefined || data.status === 'error') { + if (error || data === undefined || data.status === 'error' || data.data === undefined) { return Unable to fetch inactive settings from server. } - const setting = data.data.setting + const setting = data.data?.setting return ( diff --git a/src/components/settings/InactiveSettingsForm.tsx b/src/components/settings/InactiveSettingsForm.tsx index e66f68c5..49c6d1da 100644 --- a/src/components/settings/InactiveSettingsForm.tsx +++ b/src/components/settings/InactiveSettingsForm.tsx @@ -1,9 +1,9 @@ +import { FormGroup, Grid, TextField } from '@mui/material' +import { useQueryClient } from '@tanstack/react-query' import { FC } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' -import { useQueryClient } from '@tanstack/react-query' -import { InactiveSetting, Setting, useSettingsApi } from '../../api/Settings' +import { InactiveSetting, Setting, putInactiveSettingsApi } from '../../api/Settings' import useAuth from '../../contexts/useAuth' -import { FormGroup, Grid, TextField } from '@mui/material' interface Props { name: string @@ -34,11 +34,10 @@ const InactiveSettingsForm: FC = ({ name, setting, callback }) => { }, }) const { token } = useAuth() - const { putInactiveSettings } = useSettingsApi(token) const queryClient = useQueryClient() const onSubmit: SubmitHandler = data => { - putInactiveSettings(data) + putInactiveSettingsApi(token, data) .then(() => queryClient.invalidateQueries({ queryKey: ['inactive_settings'] })) .finally(() => callback()) } diff --git a/src/components/settings/RefreshIntervalCard.tsx b/src/components/settings/RefreshIntervalCard.tsx index 5463b7fd..86525cef 100644 --- a/src/components/settings/RefreshIntervalCard.tsx +++ b/src/components/settings/RefreshIntervalCard.tsx @@ -1,19 +1,17 @@ +import { faPencil } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { Box, Button, Card, CardContent, Divider, Stack, Typography } from '@mui/material' import { FC, useState } from 'react' -import { useQuery } from '@tanstack/react-query' -import { useSettingsApi } from '../../api/Settings' -import ErrorView from '../../views/ErrorView' import useAuth from '../../contexts/useAuth' -import { Box, Button, Card, CardContent, Divider, Stack, Typography } from '@mui/material' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faPencil } from '@fortawesome/free-solid-svg-icons' +import useRefreshIntervalSettingsQuery from '../../queries/useRefreshIntervalSettingsQuery' +import ErrorView from '../../views/ErrorView' import Loader from '../Loader' import RefreshIntervalModalForm from './RefreshIntervalModalForm' const RefreshIntervalCard: FC = () => { const [formOpen, setFormOpen] = useState(false) const { token } = useAuth() - const { getRefreshInterval } = useSettingsApi(token) - const { isLoading, error, data } = useQuery({ queryKey: ['refresh_interval_setting'], queryFn: getRefreshInterval }) + const { isLoading, error, data } = useRefreshIntervalSettingsQuery({ token }) const handleFormOpen = () => { setFormOpen(true) @@ -27,7 +25,7 @@ const RefreshIntervalCard: FC = () => { return } - if (error || data === undefined || data.status === 'error') { + if (error || data === undefined || data.data === undefined || data.status === 'error') { return Unable to fetch refresh interval setting from server. } diff --git a/src/components/settings/RefreshIntervalForm.tsx b/src/components/settings/RefreshIntervalForm.tsx index 15c86160..d8f1cc13 100644 --- a/src/components/settings/RefreshIntervalForm.tsx +++ b/src/components/settings/RefreshIntervalForm.tsx @@ -1,9 +1,9 @@ +import { FormGroup, Grid, TextField } from '@mui/material' +import { useQueryClient } from '@tanstack/react-query' import { FC } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' -import { useQueryClient } from '@tanstack/react-query' -import { RefreshIntervalSetting, Setting, useSettingsApi } from '../../api/Settings' +import { RefreshIntervalSetting, Setting, putRefreshIntervalApi } from '../../api/Settings' import useAuth from '../../contexts/useAuth' -import { FormGroup, Grid, TextField } from '@mui/material' interface Props { name: string @@ -22,11 +22,10 @@ const RefreshIntervalForm: FC = ({ name, setting, callback }) => { }, }) const { token } = useAuth() - const { putRefreshInterval } = useSettingsApi(token) const queryClient = useQueryClient() const onSubmit: SubmitHandler = data => { - putRefreshInterval(data.refreshInterval) + putRefreshIntervalApi(token, data.refreshInterval) .then(() => queryClient.invalidateQueries({ queryKey: ['refresh_interval_setting'] })) .finally(() => callback()) } diff --git a/src/components/ticker/BlueskyCard.tsx b/src/components/ticker/BlueskyCard.tsx index a49f074c..b8c24fcc 100644 --- a/src/components/ticker/BlueskyCard.tsx +++ b/src/components/ticker/BlueskyCard.tsx @@ -1,11 +1,11 @@ -import { Box, Button, Card, CardActions, CardContent, Divider, Link, Stack, Typography } from '@mui/material' -import { Ticker, useTickerApi } from '../../api/Ticker' -import useAuth from '../../contexts/useAuth' -import { FC, useCallback, useState } from 'react' -import { useQueryClient } from '@tanstack/react-query' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faBluesky } from '@fortawesome/free-brands-svg-icons' import { faGear, faPause, faPlay, 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 { Ticker, deleteTickerBlueskyApi, putTickerBlueskyApi } from '../../api/Ticker' +import useAuth from '../../contexts/useAuth' import BlueskyModalForm from './BlueskyModalForm' interface Props { @@ -14,7 +14,6 @@ interface Props { const BlueskyCard: FC = ({ ticker }) => { const { token } = useAuth() - const { deleteTickerBluesky, putTickerBluesky } = useTickerApi(token) const [open, setOpen] = useState(false) const queryClient = useQueryClient() @@ -22,16 +21,16 @@ const BlueskyCard: FC = ({ ticker }) => { const bluesky = ticker.bluesky const handleDisconnect = useCallback(() => { - deleteTickerBluesky(ticker).finally(() => { + deleteTickerBlueskyApi(token, ticker).finally(() => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) }) - }, [deleteTickerBluesky, queryClient, ticker]) + }, [token, queryClient, ticker]) const handleToggle = useCallback(() => { - putTickerBluesky({ active: !bluesky.active }, ticker).finally(() => { + putTickerBlueskyApi(token, { active: !bluesky.active }, ticker).finally(() => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) }) - }, [bluesky.active, putTickerBluesky, queryClient, ticker]) + }, [bluesky.active, token, queryClient, ticker]) const profileLink = ( diff --git a/src/components/ticker/BlueskyForm.tsx b/src/components/ticker/BlueskyForm.tsx index d22e65e9..9d0d49e4 100644 --- a/src/components/ticker/BlueskyForm.tsx +++ b/src/components/ticker/BlueskyForm.tsx @@ -1,9 +1,9 @@ +import { Alert, Checkbox, FormControlLabel, FormGroup, Grid, TextField, Typography } from '@mui/material' +import { useQueryClient } from '@tanstack/react-query' import { FC } from 'react' -import { Ticker, TickerBlueskyFormData, useTickerApi } from '../../api/Ticker' -import useAuth from '../../contexts/useAuth' import { useForm } from 'react-hook-form' -import { useQueryClient } from '@tanstack/react-query' -import { Alert, Checkbox, FormControlLabel, FormGroup, Grid, TextField, Typography } from '@mui/material' +import { Ticker, TickerBlueskyFormData, putTickerBlueskyApi } from '../../api/Ticker' +import useAuth from '../../contexts/useAuth' interface Props { callback: () => void @@ -13,7 +13,6 @@ interface Props { const BlueskyForm: FC = ({ callback, ticker }) => { const bluesky = ticker.bluesky const { token } = useAuth() - const { putTickerBluesky } = useTickerApi(token) const { formState: { errors }, handleSubmit, @@ -29,7 +28,7 @@ const BlueskyForm: FC = ({ callback, ticker }) => { const queryClient = useQueryClient() const onSubmit = handleSubmit(data => { - putTickerBluesky(data, ticker).then(response => { + putTickerBlueskyApi(token, data, ticker).then(response => { if (response.status == 'error') { setError('root.authenticationFailed', { message: 'Authentication failed' }) } else { diff --git a/src/components/ticker/MastodonCard.test.tsx b/src/components/ticker/MastodonCard.test.tsx new file mode 100644 index 00000000..1197a354 --- /dev/null +++ b/src/components/ticker/MastodonCard.test.tsx @@ -0,0 +1,104 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, 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 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) + }) + + const ticker = ({ active, connected, name = '' }: { active: boolean; connected: boolean; name?: string }) => { + return { + id: 1, + mastodon: { + active: active, + connected: connected, + name: name, + server: 'https://mastodon.social', + }, + } as Ticker + } + + beforeEach(() => { + fetchMock.resetMocks() + }) + + function setup(ticker: Ticker) { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return render( + + + + + + + + ) + } + + it('should render the component', () => { + setup(ticker({ active: false, connected: false })) + + expect(screen.getByText('Mastodon')).toBeInTheDocument() + expect(screen.getByText('You are not connected with Mastodon.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument() + }) + + it('should render the component when connected and active', async () => { + setup(ticker({ active: true, connected: true, name: 'user' })) + + expect(screen.getByText('Mastodon')).toBeInTheDocument() + expect(screen.getByText('You are connected with Mastodon.')).toBeInTheDocument() + expect(screen.getByText('Your Profile:')).toBeInTheDocument() + expect(screen.getByText('@user@mastodon.social')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Disable' })).toBeInTheDocument() + + fetchMock.mockResponseOnce(JSON.stringify({ status: 'success' })) + + screen.getByRole('button', { name: 'Disable' }).click() + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/v1/admin/tickers/1/mastodon', { + body: JSON.stringify({ active: false }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + token, + }, + method: 'put', + }) + + fetchMock.mockResponseOnce(JSON.stringify({ status: 'success' })) + + screen.getByRole('button', { name: 'Disconnect' }).click() + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/v1/admin/tickers/1/mastodon', { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + token, + }, + method: 'delete', + }) + + await userEvent.click(screen.getByRole('button', { name: 'Configure' })) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) +}) diff --git a/src/components/ticker/MastodonCard.tsx b/src/components/ticker/MastodonCard.tsx index 6a4277d2..daa576f3 100644 --- a/src/components/ticker/MastodonCard.tsx +++ b/src/components/ticker/MastodonCard.tsx @@ -1,12 +1,12 @@ -import { FC, useCallback, useState } from 'react' import { faMastodon } from '@fortawesome/free-brands-svg-icons' +import { faBan, faGear, faPause, faPlay } 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 { Ticker, useTickerApi } from '../../api/Ticker' +import { FC, useCallback, useState } from 'react' +import { Ticker, deleteTickerMastodonApi, putTickerMastodonApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import MastodonModalForm from './MastodonModalForm' -import { Box, Button, Card, CardActions, CardContent, Divider, Link, Stack, Typography } from '@mui/material' -import { faBan, faGear, faPause, faPlay } from '@fortawesome/free-solid-svg-icons' interface Props { ticker: Ticker @@ -14,7 +14,6 @@ interface Props { const MastodonCard: FC = ({ ticker }) => { const { token } = useAuth() - const { deleteTickerMastodon, putTickerMastodon } = useTickerApi(token) const [open, setOpen] = useState(false) const queryClient = useQueryClient() @@ -22,16 +21,16 @@ const MastodonCard: FC = ({ ticker }) => { const mastodon = ticker.mastodon const handleDisconnect = useCallback(() => { - deleteTickerMastodon(ticker).finally(() => { + deleteTickerMastodonApi(token, ticker).finally(() => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) }) - }, [deleteTickerMastodon, queryClient, ticker]) + }, [token, queryClient, ticker]) const handleToggle = useCallback(() => { - putTickerMastodon({ active: !mastodon.active }, ticker).finally(() => { + putTickerMastodonApi(token, { active: !mastodon.active }, ticker).finally(() => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) }) - }, [mastodon.active, putTickerMastodon, queryClient, ticker]) + }, [mastodon.active, token, queryClient, ticker]) const profileLink = ( @@ -59,9 +58,14 @@ const MastodonCard: FC = ({ ticker }) => { Your Profile: {profileLink} ) : ( - - You are currently not connected to Mastodon. New messages will not be published to your account and old messages can not be deleted anymore. - + + + You are not connected with Mastodon. + + + New messages will not be published to your account and old messages can not be deleted anymore. + + )} {mastodon.connected ? ( diff --git a/src/components/ticker/MastodonForm.test.tsx b/src/components/ticker/MastodonForm.test.tsx new file mode 100644 index 00000000..cef6a475 --- /dev/null +++ b/src/components/ticker/MastodonForm.test.tsx @@ -0,0 +1,94 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, 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 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) + }) + + const ticker = ({ active, connected, name = '' }: { active: boolean; connected: boolean; name?: string }) => { + return { + id: 1, + mastodon: { + active: active, + connected: connected, + name: name, + server: 'https://mastodon.social', + }, + } as Ticker + } + + const callback = vi.fn() + + beforeEach(() => { + fetchMock.resetMocks() + }) + + function setup(ticker: Ticker) { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return render( + + + +
+ + +
+
+
+
+ ) + } + + it('should render the component', async () => { + setup(ticker({ active: false, connected: false })) + + expect(screen.getByRole('checkbox', { name: 'Active' })).toBeInTheDocument() + expect(screen.getByLabelText('Server *')).toBeInTheDocument() + expect(screen.getByLabelText('Token *')).toBeInTheDocument() + expect(screen.getByLabelText('Secret *')).toBeInTheDocument() + expect(screen.getByLabelText('Access Token *')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument() + + await userEvent.click(screen.getByRole('checkbox', { name: 'Active' })) + await userEvent.type(screen.getByLabelText('Token *'), 'token') + await userEvent.type(screen.getByLabelText('Secret *'), 'secret') + await userEvent.type(screen.getByLabelText('Access Token *'), 'access-token') + + fetchMock.mockResponseOnce(JSON.stringify({ status: 'success' })) + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })) + + expect(callback).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/v1/admin/tickers/1/mastodon', { + body: JSON.stringify({ + active: true, + server: 'https://mastodon.social', + token: 'token', + secret: 'secret', + accessToken: 'access-token', + }), + headers: { + Accept: 'application/json', + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json', + }, + method: 'put', + }) + }) +}) diff --git a/src/components/ticker/MastodonForm.tsx b/src/components/ticker/MastodonForm.tsx index 39b96a7a..09145826 100644 --- a/src/components/ticker/MastodonForm.tsx +++ b/src/components/ticker/MastodonForm.tsx @@ -2,7 +2,7 @@ import { Checkbox, FormControlLabel, FormGroup, Grid, TextField, Typography } fr import { useQueryClient } from '@tanstack/react-query' import { FC } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' -import { Ticker, TickerMastodonFormData, useTickerApi } from '../../api/Ticker' +import { Ticker, TickerMastodonFormData, putTickerMastodonApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' interface Props { @@ -13,7 +13,6 @@ interface Props { const MastodonForm: FC = ({ callback, ticker }) => { const mastodon = ticker.mastodon const { token } = useAuth() - const { putTickerMastodon } = useTickerApi(token) const { handleSubmit, register } = useForm({ defaultValues: { active: mastodon.active, @@ -23,7 +22,7 @@ const MastodonForm: FC = ({ callback, ticker }) => { const queryClient = useQueryClient() const onSubmit: SubmitHandler = data => { - putTickerMastodon(data, ticker).finally(() => { + putTickerMastodonApi(token, data, ticker).finally(() => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) callback() }) diff --git a/src/components/ticker/TelegramCard.test.tsx b/src/components/ticker/TelegramCard.test.tsx new file mode 100644 index 00000000..50ee7eda --- /dev/null +++ b/src/components/ticker/TelegramCard.test.tsx @@ -0,0 +1,103 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, 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 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) + }) + + const ticker = ({ active, connected, channelName = '' }: { active: boolean; connected: boolean; channelName?: string }) => { + return { + id: 1, + telegram: { + active: active, + connected: connected, + channelName: channelName, + botUsername: 'bot', + }, + } as Ticker + } + + beforeEach(() => { + fetchMock.resetMocks() + }) + + function setup(ticker: Ticker) { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return render( + + + + + + + + ) + } + + it('should render the component', () => { + setup(ticker({ active: false, connected: false })) + + expect(screen.getByText('Telegram')).toBeInTheDocument() + expect(screen.getByText('You are not connected with Telegram.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument() + }) + + it('should render the component when connected and active', async () => { + setup(ticker({ active: true, connected: true, channelName: 'channel' })) + + expect(screen.getByText('Telegram')).toBeInTheDocument() + expect(screen.getByText('You are connected with Telegram.')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'channel' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Configure' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Disable' })).toBeInTheDocument() + + fetchMock.mockResponseOnce(JSON.stringify({ status: 'success' })) + + await userEvent.click(screen.getByRole('button', { name: 'Disable' })) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/v1/admin/tickers/1/telegram', { + body: JSON.stringify({ active: false }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + token, + }, + method: 'put', + }) + + fetchMock.mockResponseOnce(JSON.stringify({ status: 'success' })) + + await userEvent.click(screen.getByRole('button', { name: 'Disconnect' })) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/v1/admin/tickers/1/telegram', { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + token, + }, + method: 'delete', + }) + + await userEvent.click(screen.getByRole('button', { name: 'Configure' })) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) +}) diff --git a/src/components/ticker/TelegramCard.tsx b/src/components/ticker/TelegramCard.tsx index 84fe758d..ed96d101 100644 --- a/src/components/ticker/TelegramCard.tsx +++ b/src/components/ticker/TelegramCard.tsx @@ -1,10 +1,10 @@ -import { FC, useCallback, useState } from 'react' import { faTelegram } from '@fortawesome/free-brands-svg-icons' import { faBan, faGear, faPause, faPlay } 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 { Ticker, useTickerApi } from '../../api/Ticker' +import { FC, useCallback, useState } from 'react' +import { Ticker, deleteTickerTelegramApi, putTickerTelegramApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import TelegramModalForm from './TelegramModalForm' @@ -14,21 +14,20 @@ interface Props { const TelegramCard: FC = ({ ticker }) => { const { token } = useAuth() - const { deleteTickerTelegram, putTickerTelegram } = useTickerApi(token) const [open, setOpen] = useState(false) const queryClient = useQueryClient() const telegram = ticker.telegram const handleToggle = useCallback(() => { - putTickerTelegram({ active: !telegram.active }, ticker).finally(() => queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] })) - }, [putTickerTelegram, queryClient, telegram.active, ticker]) + putTickerTelegramApi(token, { active: !telegram.active }, ticker).finally(() => queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] })) + }, [token, queryClient, telegram.active, ticker]) const handleDisconnect = useCallback(() => { - deleteTickerTelegram(ticker).finally(() => { + deleteTickerTelegramApi(token, ticker).finally(() => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) }) - }, [deleteTickerTelegram, queryClient, ticker]) + }, [token, queryClient, ticker]) const channelLink = ( @@ -58,9 +57,10 @@ const TelegramCard: FC = ({ ticker }) => { ) : ( - - You are currently not connected to Telegram. New messages will not be published to your channel and old messages can not be deleted anymore. - + + You are not connected with Telegram. + New messages will not be published to your channel and old messages can not be deleted anymore. + )} {telegram.connected ? ( diff --git a/src/components/ticker/TelegramForm.test.tsx b/src/components/ticker/TelegramForm.test.tsx new file mode 100644 index 00000000..c1da522d --- /dev/null +++ b/src/components/ticker/TelegramForm.test.tsx @@ -0,0 +1,83 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, 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 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) + }) + + const ticker = ({ active, connected, channelName = '' }: { active: boolean; connected: boolean; channelName?: string }) => { + return { + id: 1, + telegram: { + active: active, + connected: connected, + channelName: channelName, + botUsername: 'bot', + }, + } as Ticker + } + + const callback = vi.fn() + + beforeEach(() => { + fetchMock.resetMocks() + }) + + function setup(ticker: Ticker) { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return render( + + + +
+ + +
+
+
+
+ ) + } + + it('should render the component', async () => { + setup(ticker({ active: false, connected: false })) + + expect(screen.getByRole('checkbox', { name: 'Active' })).toBeInTheDocument() + expect(screen.getByLabelText('Channel *')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument() + + await userEvent.click(screen.getByRole('checkbox', { name: 'Active' })) + await userEvent.type(screen.getByLabelText('Channel *'), '@channel') + + fetchMock.mockResponseOnce(JSON.stringify({ status: 'success' })) + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })) + + expect(callback).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/v1/admin/tickers/1/telegram', { + body: JSON.stringify({ active: true, channelName: '@channel' }), + headers: { + Accept: 'application/json', + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json', + }, + method: 'put', + }) + }) +}) diff --git a/src/components/ticker/TelegramForm.tsx b/src/components/ticker/TelegramForm.tsx index 5476032b..e3e56a41 100644 --- a/src/components/ticker/TelegramForm.tsx +++ b/src/components/ticker/TelegramForm.tsx @@ -1,9 +1,9 @@ +import { Checkbox, FormControlLabel, FormGroup, Grid, TextField, Typography } from '@mui/material' +import { useQueryClient } from '@tanstack/react-query' import { FC } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' -import { useQueryClient } from '@tanstack/react-query' -import { Ticker, useTickerApi } from '../../api/Ticker' +import { Ticker, putTickerTelegramApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' -import { Checkbox, FormControlLabel, FormGroup, Grid, TextField, Typography } from '@mui/material' interface Props { callback: () => void @@ -18,7 +18,6 @@ interface FormValues { const TelegramForm: FC = ({ callback, ticker }) => { const telegram = ticker.telegram const { token } = useAuth() - const { putTickerTelegram } = useTickerApi(token) const { handleSubmit, register } = useForm({ defaultValues: { active: telegram.active, @@ -28,7 +27,7 @@ const TelegramForm: FC = ({ callback, ticker }) => { const queryClient = useQueryClient() const onSubmit: SubmitHandler = data => { - putTickerTelegram(data, ticker).finally(() => { + putTickerTelegramApi(token, data, ticker).finally(() => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) callback() }) diff --git a/src/components/ticker/TickerListItems.tsx b/src/components/ticker/TickerListItems.tsx index bf46b0cd..a0938be0 100644 --- a/src/components/ticker/TickerListItems.tsx +++ b/src/components/ticker/TickerListItems.tsx @@ -11,7 +11,7 @@ interface Props { const TickerListItems: FC = ({ token, params }) => { const { data, isLoading, error } = useTickersQuery({ token, params: params }) - const tickers = data?.data.tickers || [] + const tickers = data?.data?.tickers || [] if (isLoading) { return ( @@ -30,7 +30,7 @@ const TickerListItems: FC = ({ token, params }) => { ) } - if (error) { + if (error || data === undefined || data.data === undefined || data.status === 'error') { return ( diff --git a/src/components/ticker/TickerModalDelete.tsx b/src/components/ticker/TickerModalDelete.tsx index d048d55c..39a47616 100644 --- a/src/components/ticker/TickerModalDelete.tsx +++ b/src/components/ticker/TickerModalDelete.tsx @@ -1,6 +1,6 @@ -import { FC, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { Ticker, useTickerApi } from '../../api/Ticker' +import { FC, useCallback } from 'react' +import { Ticker, deleteTickerApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import Modal from '../common/Modal' @@ -12,14 +12,13 @@ interface Props { const TickerModalDelete: FC = ({ open, onClose, ticker }) => { const { token } = useAuth() - const { deleteTicker } = useTickerApi(token) const queryClient = useQueryClient() const handleDelete = useCallback(() => { - deleteTicker(ticker).finally(() => { + deleteTickerApi(token, ticker).finally(() => { queryClient.invalidateQueries({ queryKey: ['tickers'] }) }) - }, [deleteTicker, ticker, queryClient]) + }, [token, ticker, queryClient]) return ( diff --git a/src/components/ticker/TickerResetModal.tsx b/src/components/ticker/TickerResetModal.tsx index 4dddd760..b86b835d 100644 --- a/src/components/ticker/TickerResetModal.tsx +++ b/src/components/ticker/TickerResetModal.tsx @@ -1,7 +1,7 @@ +import { useQueryClient } from '@tanstack/react-query' import { FC, useCallback } from 'react' -import { Ticker, useTickerApi } from '../../api/Ticker' +import { Ticker, putTickerResetApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' -import { useQueryClient } from '@tanstack/react-query' import Modal from '../common/Modal' interface Props { @@ -12,11 +12,10 @@ interface Props { const TickerResetModal: FC = ({ onClose, open, ticker }) => { const { token } = useAuth() - const { putTickerReset } = useTickerApi(token) const queryClient = useQueryClient() const handleReset = useCallback(() => { - putTickerReset(ticker) + putTickerResetApi(token, ticker) .then(() => { queryClient.invalidateQueries({ queryKey: ['messages', ticker.id] }) queryClient.invalidateQueries({ queryKey: ['tickerUsers', ticker.id] }) @@ -25,7 +24,7 @@ const TickerResetModal: FC = ({ onClose, open, ticker }) => { .finally(() => { onClose() }) - }, [onClose, putTickerReset, queryClient, ticker]) + }, [onClose, token, queryClient, ticker]) return ( diff --git a/src/components/ticker/TickerUserModalDelete.tsx b/src/components/ticker/TickerUserModalDelete.tsx index 885b8db3..932ffd20 100644 --- a/src/components/ticker/TickerUserModalDelete.tsx +++ b/src/components/ticker/TickerUserModalDelete.tsx @@ -1,6 +1,6 @@ -import { FC, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { Ticker, useTickerApi } from '../../api/Ticker' +import { FC, useCallback } from 'react' +import { Ticker, deleteTickerUserApi } from '../../api/Ticker' import { User } from '../../api/User' import useAuth from '../../contexts/useAuth' import Modal from '../common/Modal' @@ -14,15 +14,14 @@ interface Props { const TickerUserModalDelete: FC = ({ open, onClose, ticker, user }) => { const { token } = useAuth() - const { deleteTickerUser } = useTickerApi(token) const queryClient = useQueryClient() const handleDelete = useCallback(() => { - deleteTickerUser(ticker, user).finally(() => { + deleteTickerUserApi(token, ticker, user).finally(() => { queryClient.invalidateQueries({ queryKey: ['tickerUsers', ticker.id] }) onClose() }) - }, [deleteTickerUser, ticker, user, queryClient, onClose]) + }, [token, ticker, user, queryClient, onClose]) return ( diff --git a/src/components/ticker/TickerUsersCard.tsx b/src/components/ticker/TickerUsersCard.tsx index 24332d63..238aca3a 100644 --- a/src/components/ticker/TickerUsersCard.tsx +++ b/src/components/ticker/TickerUsersCard.tsx @@ -1,13 +1,13 @@ +import { faShieldDog, faUsers } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { Button, Card, CardContent, Typography } from '@mui/material' import { FC, useState } from 'react' -import { useQuery } from '@tanstack/react-query' -import { Ticker, useTickerApi } from '../../api/Ticker' -import TickerUserList from './TickerUserList' +import { Ticker } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' -import Loader from '../Loader' +import useTickerUsersQuery from '../../queries/useTickerUsersQuery' import ErrorView from '../../views/ErrorView' -import { Button, Card, CardContent, Typography } from '@mui/material' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faShieldDog, faUsers } from '@fortawesome/free-solid-svg-icons' +import Loader from '../Loader' +import TickerUserList from './TickerUserList' import TickerUsersModal from './TickerUsersModal' interface Props { @@ -16,20 +16,14 @@ interface Props { const TickerUsersCard: FC = ({ ticker }) => { const { token } = useAuth() - const { getTickerUsers } = useTickerApi(token) const [formOpen, setFormOpen] = useState(false) - const { isLoading, error, data } = useQuery({ - queryKey: ['tickerUsers', ticker.id], - queryFn: () => { - return getTickerUsers(ticker) - }, - }) + const { isLoading, error, data } = useTickerUsersQuery({ ticker, token }) if (isLoading) { return } - if (error || data === undefined || data.status === 'error') { + if (error || data === undefined || data.data === undefined || data.status === 'error') { return Unable to fetch users from server. } diff --git a/src/components/ticker/TickerUsersForm.tsx b/src/components/ticker/TickerUsersForm.tsx index 4c3f4a3e..75b98139 100644 --- a/src/components/ticker/TickerUsersForm.tsx +++ b/src/components/ticker/TickerUsersForm.tsx @@ -1,10 +1,10 @@ +import { Box, Chip, FormControl, InputLabel, MenuItem, OutlinedInput, Select, SelectChangeEvent, useTheme } from '@mui/material' +import { useQueryClient } from '@tanstack/react-query' import { FC, useEffect, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' -import { useQueryClient } from '@tanstack/react-query' -import { Ticker, useTickerApi } from '../../api/Ticker' -import { User, useUserApi } from '../../api/User' +import { Ticker, putTickerUsersApi } from '../../api/Ticker' +import { User, fetchUsersApi } from '../../api/User' import useAuth from '../../contexts/useAuth' -import { Box, Chip, FormControl, InputLabel, MenuItem, OutlinedInput, Select, SelectChangeEvent, useTheme } from '@mui/material' interface Props { ticker: Ticker @@ -20,9 +20,7 @@ const TickerUsersForm: FC = ({ onSubmit, ticker, defaultValue }) => { const [users, setUsers] = useState>(defaultValue) const [options, setOptions] = useState>([]) const { token } = useAuth() - const { getUsers } = useUserApi(token) const theme = useTheme() - const { putTickerUsers } = useTickerApi(token) const { handleSubmit } = useForm() const queryClient = useQueryClient() @@ -35,22 +33,26 @@ const TickerUsersForm: FC = ({ onSubmit, ticker, defaultValue }) => { } const updateTickerUsers: SubmitHandler = () => { - putTickerUsers(ticker, users).then(() => { + putTickerUsersApi(token, ticker, users).then(() => { queryClient.invalidateQueries({ queryKey: ['tickerUsers', ticker.id] }) onSubmit() }) } useEffect(() => { - getUsers() - .then(response => response.data.users) - .then(users => + fetchUsersApi(token) + .then(response => response.data?.users) + .then(users => { + if (!users) { + return + } + setOptions( users.filter(user => { return !user.isSuperAdmin }) ) - ) + }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) diff --git a/src/components/ticker/TickersDropdown.tsx b/src/components/ticker/TickersDropdown.tsx index d4ba72d4..fbbe7871 100644 --- a/src/components/ticker/TickersDropdown.tsx +++ b/src/components/ticker/TickersDropdown.tsx @@ -1,6 +1,6 @@ -import { FC, useEffect, useState } from 'react' import { Box, Chip, FormControl, InputLabel, MenuItem, OutlinedInput, Select, SelectChangeEvent, SxProps, useTheme } from '@mui/material' -import { GetTickersQueryParams, Ticker, useTickerApi } from '../../api/Ticker' +import { FC, useEffect, useState } from 'react' +import { GetTickersQueryParams, Ticker, fetchTickersApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' interface Props { @@ -14,7 +14,6 @@ const TickersDropdown: FC = ({ name, defaultValue, onChange, sx }) => { const [options, setOptions] = useState>([]) const [tickers, setTickers] = useState>(defaultValue) const { token } = useAuth() - const { getTickers } = useTickerApi(token) const theme = useTheme() const handleChange = (event: SelectChangeEvent) => { @@ -28,9 +27,10 @@ const TickersDropdown: FC = ({ name, defaultValue, onChange, sx }) => { useEffect(() => { const params = {} as GetTickersQueryParams - getTickers(params) - .then(response => response.data.tickers) + fetchTickersApi(token, params) + .then(response => response.data?.tickers) .then(tickers => { + if (!tickers) return setOptions(tickers) }) // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/components/ticker/form/TickerForm.tsx b/src/components/ticker/form/TickerForm.tsx index f028e57b..029f774d 100644 --- a/src/components/ticker/form/TickerForm.tsx +++ b/src/components/ticker/form/TickerForm.tsx @@ -1,23 +1,23 @@ +import { Alert, Button, FormGroup, Grid, Stack, Typography } from '@mui/material' +import { useQueryClient } from '@tanstack/react-query' import React, { FC, useCallback, useEffect } from 'react' -import { Ticker, useTickerApi } from '../../../api/Ticker' import { FormProvider, SubmitHandler, useForm } from 'react-hook-form' -import { useQueryClient } from '@tanstack/react-query' +import { MapContainer, Marker, TileLayer } from 'react-leaflet' +import { Ticker, TickerFormData, postTickerApi, putTickerApi } from '../../../api/Ticker' import useAuth from '../../../contexts/useAuth' import LocationSearch, { Result } from '../LocationSearch' -import { MapContainer, Marker, TileLayer } from 'react-leaflet' -import { Alert, Button, FormGroup, Grid, Stack, Typography } from '@mui/material' -import Title from './Title' -import Domain from './Domain' import Active from './Active' -import Description from './Description' import Author from './Author' -import Url from './Url' -import Facebook from './Facebook' -import Telegram from './Telegram' -import Mastodon from './Mastodon' import Bluesky from './Bluesky' +import Description from './Description' +import Domain from './Domain' import Email from './Email' +import Facebook from './Facebook' +import Mastodon from './Mastodon' +import Telegram from './Telegram' +import Title from './Title' import Twitter from './Twitter' +import Url from './Url' interface Props { id: string @@ -25,29 +25,8 @@ interface Props { callback: () => void } -interface FormValues { - title: string - domain: string - active: boolean - description: string - information: { - author: string - email: string - url: string - twitter: string - facebook: string - telegram: string - mastodon: string - bluesky: string - } - location: { - lat: number - lon: number - } -} - const TickerForm: FC = ({ callback, id, ticker }) => { - const form = useForm({ + const form = useForm({ defaultValues: { title: ticker?.title, domain: ticker?.domain, @@ -70,7 +49,6 @@ const TickerForm: FC = ({ callback, id, ticker }) => { }, }) const { token } = useAuth() - const { postTicker, putTicker } = useTickerApi(token) const queryClient = useQueryClient() const { handleSubmit, register, setValue, watch } = form @@ -92,15 +70,15 @@ const TickerForm: FC = ({ callback, id, ticker }) => { [setValue] ) - const onSubmit: SubmitHandler = data => { + const onSubmit: SubmitHandler = data => { if (ticker) { - putTicker(data, ticker.id).finally(() => { + putTickerApi(token, data, ticker.id).finally(() => { queryClient.invalidateQueries({ queryKey: ['tickers'] }) queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) callback() }) } else { - postTicker(data).finally(() => { + postTickerApi(token, data).finally(() => { queryClient.invalidateQueries({ queryKey: ['tickers'] }) callback() }) diff --git a/src/components/user/UserChangePasswordForm.tsx b/src/components/user/UserChangePasswordForm.tsx index f767c184..66a3e27f 100644 --- a/src/components/user/UserChangePasswordForm.tsx +++ b/src/components/user/UserChangePasswordForm.tsx @@ -1,8 +1,8 @@ -import { FC } from 'react' import { Alert, FormGroup, Grid, TextField } from '@mui/material' +import { FC } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' +import { putMeApi } from '../../api/User' import useAuth from '../../contexts/useAuth' -import { useUserApi } from '../../api/User' interface Props { id: string @@ -24,10 +24,9 @@ const UserChangePasswordForm: FC = ({ id, onClose }) => { watch, } = useForm() const { token } = useAuth() - const { putMe } = useUserApi(token) const onSubmit: SubmitHandler = data => { - putMe(data).then(response => { + putMeApi(token, data).then(response => { if (response.status === 'error') { const message = response.error?.message === 'could not authenticate password' ? 'Wrong password' : 'Something went wrong' diff --git a/src/components/user/UserChangePasswordModalForm.test.tsx b/src/components/user/UserChangePasswordModalForm.test.tsx index 99a4b581..f2ade9eb 100644 --- a/src/components/user/UserChangePasswordModalForm.test.tsx +++ b/src/components/user/UserChangePasswordModalForm.test.tsx @@ -1,14 +1,14 @@ -import { render, screen } from '@testing-library/react' -import UserChangePasswordModalForm from './UserChangePasswordModalForm' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { MemoryRouter } from 'react-router' +import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router' import { vi } from 'vitest' import { AuthProvider } from '../../contexts/AuthContext' +import UserChangePasswordModalForm from './UserChangePasswordModalForm' describe('UserChangePasswordModalForm', () => { beforeEach(() => { - fetch.resetMocks() + fetchMock.resetMocks() }) function setup(open: boolean, onClose: () => void) { @@ -47,7 +47,7 @@ describe('UserChangePasswordModalForm', () => { const onClose = vi.fn() setup(true, onClose) - fetch.mockResponseOnce( + fetchMock.mockResponseOnce( JSON.stringify({ data: { user: { @@ -83,14 +83,14 @@ describe('UserChangePasswordModalForm', () => { const onClose = vi.fn() setup(true, onClose) - fetch.mockResponseOnce( + fetchMock.mockResponseOnce( JSON.stringify({ error: { message: 'could not authenticate password', }, status: 'error', }), - { status: 401 } + { status: 200 } ) await userEvent.type(screen.getByLabelText('Password *'), 'password') diff --git a/src/components/user/UserForm.tsx b/src/components/user/UserForm.tsx index 68f696fe..d6380fb8 100644 --- a/src/components/user/UserForm.tsx +++ b/src/components/user/UserForm.tsx @@ -1,11 +1,11 @@ +import { Checkbox, Divider, FormControlLabel, FormGroup, Grid, TextField, Typography } from '@mui/material' +import { useQueryClient } from '@tanstack/react-query' import { FC, useEffect } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' -import { User, useUserApi } from '../../api/User' -import { useQueryClient } from '@tanstack/react-query' +import { Ticker } from '../../api/Ticker' +import { User, postUserApi, putUserApi } from '../../api/User' import useAuth from '../../contexts/useAuth' -import { FormControlLabel, Checkbox, FormGroup, TextField, Typography, Grid, Divider } from '@mui/material' import TickersDropdown from '../ticker/TickersDropdown' -import { Ticker } from '../../api/Ticker' interface Props { id: string @@ -23,7 +23,6 @@ interface FormValues { const UserForm: FC = ({ id, user, callback }) => { const { token } = useAuth() - const { postUser, putUser } = useUserApi(token) const { formState: { errors }, handleSubmit, @@ -50,12 +49,12 @@ const UserForm: FC = ({ id, user, callback }) => { } if (user) { - putUser(formData, user).finally(() => { + putUserApi(token, user, formData).finally(() => { queryClient.invalidateQueries({ queryKey: ['users'] }) callback() }) } else { - postUser(formData).finally(() => { + postUserApi(token, formData).finally(() => { queryClient.invalidateQueries({ queryKey: ['users'] }) callback() }) diff --git a/src/components/user/UserList.tsx b/src/components/user/UserList.tsx index b0b85458..2ccbf255 100644 --- a/src/components/user/UserList.tsx +++ b/src/components/user/UserList.tsx @@ -1,16 +1,14 @@ +import { Table, TableCell, TableContainer, TableHead, TableRow } from '@mui/material' import { FC } from 'react' -import { useQuery } from '@tanstack/react-query' -import UserListItems from './UserListItems' import useAuth from '../../contexts/useAuth' -import { useUserApi } from '../../api/User' +import useUsersQuery from '../../queries/useUsersQuery' import ErrorView from '../../views/ErrorView' -import { Table, TableCell, TableContainer, TableHead, TableRow } from '@mui/material' import Loader from '../Loader' +import UserListItems from './UserListItems' const UserList: FC = () => { const { token } = useAuth() - const { getUsers } = useUserApi(token) - const { isLoading, error, data } = useQuery({ queryKey: ['users'], queryFn: getUsers }) + const { isLoading, error, data } = useUsersQuery({ token }) if (isLoading) { return @@ -20,7 +18,7 @@ const UserList: FC = () => { return Unable to fetch users from server. } - const users = data.data.users + const users = data.data?.users || [] return ( diff --git a/src/components/user/UserModalDelete.tsx b/src/components/user/UserModalDelete.tsx index 0ee7863d..2a446cac 100644 --- a/src/components/user/UserModalDelete.tsx +++ b/src/components/user/UserModalDelete.tsx @@ -1,6 +1,6 @@ -import { FC, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { User, useUserApi } from '../../api/User' +import { FC, useCallback } from 'react' +import { User, deleteUserApi } from '../../api/User' import useAuth from '../../contexts/useAuth' import Modal from '../common/Modal' @@ -12,15 +12,14 @@ interface Props { const UserModalDelete: FC = ({ onClose, open, user }) => { const { token } = useAuth() - const { deleteUser } = useUserApi(token) const queryClient = useQueryClient() const handleDelete = useCallback(() => { - deleteUser(user).finally(() => { + deleteUserApi(token, user).finally(() => { queryClient.invalidateQueries({ queryKey: ['users'] }) onClose() }) - }, [deleteUser, user, queryClient, onClose]) + }, [token, user, queryClient, onClose]) return ( diff --git a/src/contexts/FeatureContext.test.tsx b/src/contexts/FeatureContext.test.tsx index 5a8dec94..760c5f2a 100644 --- a/src/contexts/FeatureContext.test.tsx +++ b/src/contexts/FeatureContext.test.tsx @@ -1,8 +1,8 @@ import { render, screen } from '@testing-library/react' -import FeatureContext, { FeatureProvider } from './FeatureContext' -import { AuthProvider, Roles } from './AuthContext' -import { MemoryRouter } from 'react-router' import sign from 'jwt-encode' +import { MemoryRouter } from 'react-router' +import { AuthProvider, Roles } from './AuthContext' +import FeatureContext, { FeatureProvider } from './FeatureContext' const exp = Math.floor(Date.now() / 1000) + 5000 const token = sign({ id: 1, email: 'user@example.org', roles: ['user'] as Array, exp: exp }, 'secret') @@ -31,7 +31,7 @@ describe('FeatureContext', () => { - {value =>
{value?.telegramEnabled.toString()}
}
+ {value =>
{value?.features.telegramEnabled.toString()}
}
@@ -47,4 +47,14 @@ describe('FeatureContext', () => { expect(await screen.findByText('true')).toBeInTheDocument() expect(fetchMock).toHaveBeenCalledTimes(1) }) + + it('should handle error', async () => { + mockedLocalStorage.getItem = vi.fn(() => token) + fetchMock.mockResponseOnce(JSON.stringify({ status: 'error', error: { code: 500, message: 'Internal Server Error' } })) + + setup() + + expect(await screen.findByText('false')).toBeInTheDocument() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/contexts/FeatureContext.tsx b/src/contexts/FeatureContext.tsx index 52f9eedf..1f88a898 100644 --- a/src/contexts/FeatureContext.tsx +++ b/src/contexts/FeatureContext.tsx @@ -1,8 +1,15 @@ -import { createContext, ReactNode, useEffect, useMemo, useState } from 'react' -import { Features, useFeatureApi } from '../api/Features' +import { createContext, ReactNode, useCallback, useEffect, useMemo, useState } from 'react' +import { ApiResponse } from '../api/Api' +import { Features, fetchFeaturesApi } from '../api/Features' import useAuth from './useAuth' -const FeatureContext = createContext(undefined) +interface FeatureContextType { + features: Features + loading: boolean + error: string | null +} + +const FeatureContext = createContext(undefined) const initalState: Features = { telegramEnabled: false, @@ -10,36 +17,44 @@ const initalState: Features = { export function FeatureProvider({ children }: Readonly<{ children: ReactNode }>): JSX.Element { const [features, setFeatures] = useState(initalState) - const [loadingInitial, setLoadingInitial] = useState(true) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) const { token } = useAuth() - const { getFeatures } = useFeatureApi(token) - useEffect(() => { + const fetchFeatures = useCallback(async () => { if (token === '') { - setLoadingInitial(false) + setLoading(false) return } - getFeatures() - .then(response => { - if (response.status === 'success') { - setFeatures(response.data.features) - } - }) - .finally(() => { - setLoadingInitial(false) - }) - // eslint-disable-next-line react-hooks/exhaustive-deps + setLoading(true) + setError(null) + + const result: ApiResponse<{ features: Features }> = await fetchFeaturesApi(token) + + if (result.error) { + setError(result.error.message) + } else if (result.data) { + setFeatures(result.data.features) + } + + setLoading(false) }, [token]) - const memoedValue = useMemo( + useEffect(() => { + fetchFeatures() + }, [fetchFeatures]) + + const contextValue = useMemo( () => ({ - telegramEnabled: features.telegramEnabled, + features, + loading, + error, }), - [features] + [features, loading, error] ) - return {!loadingInitial && children} + return {children} } export default FeatureContext diff --git a/src/contexts/useFeature.test.tsx b/src/contexts/useFeature.test.tsx index ea5f9411..0d4c0a91 100644 --- a/src/contexts/useFeature.test.tsx +++ b/src/contexts/useFeature.test.tsx @@ -1,9 +1,9 @@ import { renderHook } from '@testing-library/react-hooks' -import useFeature from './useFeature' -import { FeatureProvider } from './FeatureContext' import { ReactNode } from 'react' -import { AuthProvider } from './AuthContext' import { MemoryRouter } from 'react-router' +import { AuthProvider } from './AuthContext' +import { FeatureProvider } from './FeatureContext' +import useFeature from './useFeature' describe('useFeature', () => { it('throws error when not rendered within FeatureProvider', () => { @@ -22,6 +22,6 @@ describe('useFeature', () => { ) const { result } = renderHook(() => useFeature(), { wrapper }) - expect(result.current).toEqual({ telegramEnabled: false }) + expect(result.current).toEqual({ error: null, features: { telegramEnabled: false }, loading: false }) }) }) diff --git a/src/queries/useInactiveSettingsQuery.tsx b/src/queries/useInactiveSettingsQuery.tsx new file mode 100644 index 00000000..0ddea0be --- /dev/null +++ b/src/queries/useInactiveSettingsQuery.tsx @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchInactiveSettingsApi } from '../api/Settings' + +interface Props { + token: string +} + +const useInactiveSettingsQuery = ({ token }: Props) => { + return useQuery({ queryKey: ['inactive_settings'], queryFn: () => fetchInactiveSettingsApi(token) }) +} + +export default useInactiveSettingsQuery diff --git a/src/queries/useMessagesQuery.tsx b/src/queries/useMessagesQuery.tsx new file mode 100644 index 00000000..eb258837 --- /dev/null +++ b/src/queries/useMessagesQuery.tsx @@ -0,0 +1,23 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { fetchMessagesApi } from '../api/Message' +import { Ticker } from '../api/Ticker' + +interface Props { + token: string + ticker: Ticker +} + +const useMessagesQuery = ({ token, ticker }: Props) => { + return useInfiniteQuery({ + queryKey: ['messages', ticker.id], + queryFn: ({ pageParam = 0 }) => { + return fetchMessagesApi(token, ticker.id, pageParam) + }, + initialPageParam: 0, + getNextPageParam: lastPage => { + return lastPage?.data?.messages.length === 10 ? lastPage.data.messages.slice(-1).pop()?.id : undefined + }, + }) +} + +export default useMessagesQuery diff --git a/src/queries/useRefreshIntervalSettingsQuery.tsx b/src/queries/useRefreshIntervalSettingsQuery.tsx new file mode 100644 index 00000000..b47d450f --- /dev/null +++ b/src/queries/useRefreshIntervalSettingsQuery.tsx @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchRefreshIntervalApi } from '../api/Settings' + +interface Props { + token: string +} + +const useRefreshIntervalSettingsQuery = ({ token }: Props) => { + return useQuery({ queryKey: ['refresh_interval_setting'], queryFn: () => fetchRefreshIntervalApi(token) }) +} + +export default useRefreshIntervalSettingsQuery diff --git a/src/queries/useTickerQuery.tsx b/src/queries/useTickerQuery.tsx index 4e9da302..5e2f3211 100644 --- a/src/queries/useTickerQuery.tsx +++ b/src/queries/useTickerQuery.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { useTickerApi } from '../api/Ticker' +import { fetchTickerApi } from '../api/Ticker' interface Props { id: number @@ -7,11 +7,9 @@ interface Props { } const useTickerQuery = ({ id, token }: Props) => { - const { getTicker } = useTickerApi(token) - return useQuery({ queryKey: ['ticker', id], - queryFn: () => getTicker(id), + queryFn: () => fetchTickerApi(token, id), }) } diff --git a/src/queries/useTickerUsersQuery.tsx b/src/queries/useTickerUsersQuery.tsx new file mode 100644 index 00000000..16ecf004 --- /dev/null +++ b/src/queries/useTickerUsersQuery.tsx @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query' +import { Ticker, fetchTickerUsersApi } from '../api/Ticker' + +interface Props { + ticker: Ticker + token: string +} + +const useTickerUsersQuery = ({ ticker, token }: Props) => { + return useQuery({ + queryKey: ['tickerUsers'], + queryFn: () => fetchTickerUsersApi(token, ticker), + }) +} + +export default useTickerUsersQuery diff --git a/src/queries/useTickersQuery.tsx b/src/queries/useTickersQuery.tsx index 9ef06210..44691644 100644 --- a/src/queries/useTickersQuery.tsx +++ b/src/queries/useTickersQuery.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { GetTickersQueryParams, useTickerApi } from '../api/Ticker' +import { GetTickersQueryParams, fetchTickersApi } from '../api/Ticker' interface Props { token: string @@ -7,11 +7,9 @@ interface Props { } const useTickersQuery = ({ token, params }: Props) => { - const { getTickers } = useTickerApi(token) - return useQuery({ queryKey: ['tickers', params], - queryFn: () => getTickers(params), + queryFn: () => fetchTickersApi(token, params), placeholderData: previousData => previousData, }) } diff --git a/src/queries/useUsersQuery.tsx b/src/queries/useUsersQuery.tsx new file mode 100644 index 00000000..04147bae --- /dev/null +++ b/src/queries/useUsersQuery.tsx @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchUsersApi } from '../api/User' + +interface Props { + token: string +} + +const useUsersQuery = ({ token }: Props) => { + return useQuery({ queryKey: ['users'], queryFn: () => fetchUsersApi(token) }) +} + +export default useUsersQuery diff --git a/src/views/HomeView.test.tsx b/src/views/HomeView.test.tsx index 1c079dbf..52a61a9f 100644 --- a/src/views/HomeView.test.tsx +++ b/src/views/HomeView.test.tsx @@ -45,7 +45,6 @@ describe('HomeView', () => { expect(fetchMock).toHaveBeenCalledTimes(1) expect(screen.getByText(/loading/i)).toBeInTheDocument() - expect(await screen.findByText('Unable to fetch tickers from server.')).toBeInTheDocument() }) it('should render tickers list', async () => { @@ -65,8 +64,6 @@ describe('HomeView', () => { setup() - expect(fetchMock).toHaveBeenCalledTimes(2) - expect(screen.getByText(/loading/i)).toBeInTheDocument() expect(await screen.findByText('Tickers')).toBeInTheDocument() }) diff --git a/src/views/HomeView.tsx b/src/views/HomeView.tsx index b1733fcf..f87569bf 100644 --- a/src/views/HomeView.tsx +++ b/src/views/HomeView.tsx @@ -40,7 +40,7 @@ const HomeView: FC = () => { ) } - const tickers = data?.data.tickers || [] + const tickers = data?.data?.tickers || [] if (!user?.roles.includes('admin') && tickers.length === 1) { return