Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/Oops.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Oops error={mockError} />);
Expand Down
9 changes: 8 additions & 1 deletion src/components/Oops.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ export const Oops: FC<IProps> = ({ error }) => {
<h2 className="font-semibold text-xl mb-2 text-semibold">
{error.title}
</h2>
<div>{error.description}</div>
{error.descriptions.map((description, i) => {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: using index for key to keep the error constants clean
<div className="text-center mb-2" key={`error_description_${i}`}>
{description}
</div>
);
})}
</div>
);
};
4 changes: 3 additions & 1 deletion src/components/__snapshots__/Oops.test.tsx.snap

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

273 changes: 207 additions & 66 deletions src/hooks/useNotifications.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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());

Expand All @@ -70,107 +73,245 @@ 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());

act(() => {
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);
});
});
});
Expand Down
12 changes: 11 additions & 1 deletion src/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -405,6 +405,16 @@ export const useNotifications = (): NotificationsState => {
};

function determineFailureType(err: AxiosError<GithubRESTError>): 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;

Expand Down
Loading