diff --git a/src/components/Oops.test.tsx b/src/components/Oops.test.tsx index 601f9ef0f..3d500f024 100644 --- a/src/components/Oops.test.tsx +++ b/src/components/Oops.test.tsx @@ -6,7 +6,7 @@ describe('components/Oops.tsx', () => { it('should render itself & its children', () => { const mockError = { title: 'Error title', - description: 'Error description', + descriptions: ['Error description'], emojis: ['🔥'], }; const tree = TestRenderer.create(); diff --git a/src/components/Oops.tsx b/src/components/Oops.tsx index 2b6676464..e7a0201ab 100644 --- a/src/components/Oops.tsx +++ b/src/components/Oops.tsx @@ -19,7 +19,14 @@ export const Oops: FC = ({ error }) => {

{error.title}

-
{error.description}
+ {error.descriptions.map((description, i) => { + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: using index for key to keep the error constants clean +
+ {description} +
+ ); + })} ); }; diff --git a/src/components/__snapshots__/Oops.test.tsx.snap b/src/components/__snapshots__/Oops.test.tsx.snap index 4d777f8f1..614aca51f 100644 --- a/src/components/__snapshots__/Oops.test.tsx.snap +++ b/src/components/__snapshots__/Oops.test.tsx.snap @@ -14,7 +14,9 @@ exports[`components/Oops.tsx should render itself & its children 1`] = ` > Error title -
+
Error description
diff --git a/src/hooks/useNotifications.test.ts b/src/hooks/useNotifications.test.ts index 2a41d9185..f4cd786ed 100644 --- a/src/hooks/useNotifications.test.ts +++ b/src/hooks/useNotifications.test.ts @@ -1,5 +1,5 @@ import { act, renderHook, waitFor } from '@testing-library/react'; -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; import nock from 'nock'; import { mockAccounts, mockSettings } from '../__mocks__/mock-state'; @@ -50,17 +50,20 @@ describe('hooks/useNotifications.ts', () => { }); describe('should fetch notifications with failures - github.com & enterprise', () => { - it('bad credentials', async () => { - const status = 401; - const message = 'Bad credentials'; + it('network error', async () => { + const code = AxiosError.ERR_NETWORK; nock('https://api.github.com/') .get('/notifications?participating=false') - .reply(status, { message }); + .replyWithError({ + code: code, + }); nock('https://github.gitify.io/api/v3/') .get('/notifications?participating=false') - .reply(status, { message }); + .replyWithError({ + code: code, + }); const { result } = renderHook(() => useNotifications()); @@ -70,93 +73,234 @@ describe('hooks/useNotifications.ts', () => { await waitFor(() => { expect(result.current.requestFailed).toBe(true); - expect(result.current.errorDetails).toBe(Errors.BAD_CREDENTIALS); + expect(result.current.errorDetails).toBe(Errors.NETWORK); }); }); - it('missing scopes', async () => { - const status = 403; - const message = "Missing the 'notifications' scope"; - - nock('https://api.github.com/') - .get('/notifications?participating=false') - .reply(status, { message }); + describe('bad request errors', () => { + it('bad credentials', async () => { + const code = AxiosError.ERR_BAD_REQUEST; + const status = 401; + const message = 'Bad credentials'; + + nock('https://api.github.com/') + .get('/notifications?participating=false') + .replyWithError({ + code, + response: { + status, + data: { + message, + }, + }, + }); + + nock('https://github.gitify.io/api/v3/') + .get('/notifications?participating=false') + .replyWithError({ + code, + response: { + status, + data: { + message, + }, + }, + }); - nock('https://github.gitify.io/api/v3/') - .get('/notifications?participating=false') - .reply(status, { message }); + const { result } = renderHook(() => useNotifications()); - const { result } = renderHook(() => useNotifications()); + act(() => { + result.current.fetchNotifications(mockAccounts, mockSettings); + }); - act(() => { - result.current.fetchNotifications(mockAccounts, mockSettings); + await waitFor(() => { + expect(result.current.requestFailed).toBe(true); + expect(result.current.errorDetails).toBe(Errors.BAD_CREDENTIALS); + }); }); - await waitFor(() => { - expect(result.current.requestFailed).toBe(true); - expect(result.current.errorDetails).toBe(Errors.MISSING_SCOPES); - }); - }); + it('missing scopes', async () => { + const code = AxiosError.ERR_BAD_REQUEST; + const status = 403; + const message = "Missing the 'notifications' scope"; + + nock('https://api.github.com/') + .get('/notifications?participating=false') + .replyWithError({ + code, + response: { + status, + data: { + message, + }, + }, + }); + + nock('https://github.gitify.io/api/v3/') + .get('/notifications?participating=false') + .replyWithError({ + code, + response: { + status, + data: { + message, + }, + }, + }); - it('rate limited - primary', async () => { - const status = 403; - const message = 'API rate limit exceeded'; + const { result } = renderHook(() => useNotifications()); - nock('https://api.github.com/') - .get('/notifications?participating=false') - .reply(status, { message }); + act(() => { + result.current.fetchNotifications(mockAccounts, mockSettings); + }); - nock('https://github.gitify.io/api/v3/') - .get('/notifications?participating=false') - .reply(status, { message }); + await waitFor(() => { + expect(result.current.requestFailed).toBe(true); + expect(result.current.errorDetails).toBe(Errors.MISSING_SCOPES); + }); + }); - const { result } = renderHook(() => useNotifications()); + it('rate limited - primary', async () => { + const code = AxiosError.ERR_BAD_REQUEST; + const status = 403; + const message = 'API rate limit exceeded'; + + nock('https://api.github.com/') + .get('/notifications?participating=false') + .replyWithError({ + code, + response: { + status, + data: { + message, + }, + }, + }); + + nock('https://github.gitify.io/api/v3/') + .get('/notifications?participating=false') + .replyWithError({ + code, + response: { + status, + data: { + message, + }, + }, + }); - act(() => { - result.current.fetchNotifications(mockAccounts, mockSettings); + const { result } = renderHook(() => useNotifications()); + + act(() => { + result.current.fetchNotifications(mockAccounts, mockSettings); + }); + + await waitFor(() => { + expect(result.current.requestFailed).toBe(true); + expect(result.current.errorDetails).toBe(Errors.RATE_LIMITED); + }); }); - await waitFor(() => { - expect(result.current.requestFailed).toBe(true); - expect(result.current.errorDetails).toBe(Errors.RATE_LIMITED); + it('rate limited - secondary', async () => { + const code = AxiosError.ERR_BAD_REQUEST; + const status = 403; + const message = 'You have exceeded a secondary rate limit'; + + nock('https://api.github.com/') + .get('/notifications?participating=false') + .replyWithError({ + code, + response: { + status, + data: { + message, + }, + }, + }); + + nock('https://github.gitify.io/api/v3/') + .get('/notifications?participating=false') + .replyWithError({ + code, + response: { + status, + data: { + message, + }, + }, + }); + + const { result } = renderHook(() => useNotifications()); + + act(() => { + result.current.fetchNotifications(mockAccounts, mockSettings); + }); + + await waitFor(() => { + expect(result.current.requestFailed).toBe(true); + expect(result.current.errorDetails).toBe(Errors.RATE_LIMITED); + }); }); - }); - it('rate limited - secondary', async () => { - const status = 403; - const message = 'You have exceeded a secondary rate limit'; + it('unhandled bad request error', async () => { + const code = AxiosError.ERR_BAD_REQUEST; + const status = 400; + const message = 'Oops! Something went wrong.'; + + nock('https://api.github.com/') + .get('/notifications?participating=false') + .replyWithError({ + code, + response: { + status, + data: { + message, + }, + }, + }); + + nock('https://github.gitify.io/api/v3/') + .get('/notifications?participating=false') + .replyWithError({ + code, + response: { + status, + data: { + message, + }, + }, + }); - nock('https://api.github.com/') - .get('/notifications?participating=false') - .reply(status, { message }); + const { result } = renderHook(() => useNotifications()); - nock('https://github.gitify.io/api/v3/') - .get('/notifications?participating=false') - .reply(status, { message }); + act(() => { + result.current.fetchNotifications(mockAccounts, mockSettings); + }); - const { result } = renderHook(() => useNotifications()); + expect(result.current.isFetching).toBe(true); - act(() => { - result.current.fetchNotifications(mockAccounts, mockSettings); - }); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); - await waitFor(() => { expect(result.current.requestFailed).toBe(true); - expect(result.current.errorDetails).toBe(Errors.RATE_LIMITED); }); }); - it('default error', async () => { - const status = 400; - const message = 'Oops! Something went wrong.'; + it('unknown error', async () => { + const code = 'anything'; nock('https://api.github.com/') .get('/notifications?participating=false') - .reply(status, { message }); + .replyWithError({ + code: code, + }); nock('https://github.gitify.io/api/v3/') .get('/notifications?participating=false') - .reply(status, { message }); + .replyWithError({ + code: code, + }); const { result } = renderHook(() => useNotifications()); @@ -164,13 +308,10 @@ describe('hooks/useNotifications.ts', () => { result.current.fetchNotifications(mockAccounts, mockSettings); }); - expect(result.current.isFetching).toBe(true); - await waitFor(() => { - expect(result.current.isFetching).toBe(false); + expect(result.current.requestFailed).toBe(true); + expect(result.current.errorDetails).toBe(Errors.UNKNOWN); }); - - expect(result.current.requestFailed).toBe(true); }); }); }); diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 6fd6f7089..6489147fe 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -1,4 +1,4 @@ -import axios, { type AxiosError, type AxiosPromise } from 'axios'; +import axios, { AxiosError, type AxiosPromise } from 'axios'; import { useCallback, useState } from 'react'; import type { @@ -405,6 +405,16 @@ export const useNotifications = (): NotificationsState => { }; function determineFailureType(err: AxiosError): GitifyError { + const code = err.code; + + if (code === AxiosError.ERR_NETWORK) { + return Errors.NETWORK; + } + + if (code !== AxiosError.ERR_BAD_REQUEST) { + return Errors.UNKNOWN; + } + const status = err.response.status; const message = err.response.data.message; diff --git a/src/routes/__snapshots__/Notifications.test.tsx.snap b/src/routes/__snapshots__/Notifications.test.tsx.snap index 6dd25e226..343e755d2 100644 --- a/src/routes/__snapshots__/Notifications.test.tsx.snap +++ b/src/routes/__snapshots__/Notifications.test.tsx.snap @@ -6,7 +6,9 @@ exports[`routes/Notifications.tsx should render itself & its children (error con = { BAD_CREDENTIALS: { title: 'Bad Credentials', - description: 'Your credentials are either invalid or expired.', + descriptions: ['Your credentials are either invalid or expired.'], emojis: ['🔓'], }, MISSING_SCOPES: { title: 'Missing Scopes', - description: 'Your credentials are missing a required API scope.', + descriptions: ['Your credentials are missing a required API scope.'], emojis: ['🙃'], }, + NETWORK: { + title: 'Network Error', + descriptions: [ + 'Unable to connect to one or more of your GitHub environments.', + 'Please check your network connection, including whether you require a VPN, and try again.', + ], + emojis: ['🛜'], + }, RATE_LIMITED: { title: 'Rate Limited', - description: 'Please wait a while before trying again.', + descriptions: ['Please wait a while before trying again.'], emojis: ['😮‍💨'], }, UNKNOWN: { title: 'Oops! Something went wrong', - description: 'Please try again later.', + descriptions: ['Please try again later.'], emojis: ['🤔', '😞', '😤', '😱', '😭'], }, };