Skip to content

Commit d149a02

Browse files
committed
Separate api handling from SearchBox component
1 parent 1fd374f commit d149a02

File tree

12 files changed

+294
-46
lines changed

12 files changed

+294
-46
lines changed

views/interactivity/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
"@testing-library/user-event": "^13.2.1",
99
"@types/jest": "^27.0.1",
1010
"@types/node": "^16.7.13",
11+
"@types/pako": "^2.0.3",
1112
"@types/react": "^18.0.0",
1213
"@types/react-dom": "^18.0.0",
1314
"eslint-plugin-prettier": "^5.2.1",
15+
"pako": "^2.1.0",
1416
"react": "^18.3.1",
1517
"react-app-rewired": "^2.2.1",
1618
"react-dom": "^18.3.1",

views/interactivity/src/App.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,35 @@
1-
import React, { useState } from 'react';
1+
import React, { useState, useEffect } from 'react';
22
import './App.css';
3+
import { DictionaryApi } from './components/api/dictionary';
34
import SearchBox from './components/search-box/SearchBox';
45
import { DictionaryEntry } from './types';
56

67
function App() {
8+
const [bearerToken, setBearerToken] = useState<string | null>(null);
79
// eslint-disable-next-line @typescript-eslint/no-unused-vars
810
const [response, setResponse] = useState<DictionaryEntry | null>(null);
911
// eslint-disable-next-line @typescript-eslint/no-unused-vars
1012
const [errorMessage, setErrorMessage] = useState<string | null>(null);
1113

12-
return <SearchBox onResponse={setResponse} onError={setErrorMessage} />;
14+
useEffect(() => {
15+
const token = document.querySelector('meta[name="jwt-token"]');
16+
if (token) {
17+
setBearerToken(token.getAttribute('content'));
18+
}
19+
}, []);
20+
21+
if (!bearerToken) {
22+
return <></>;
23+
}
24+
25+
const dictionaryApi = new DictionaryApi({ token: bearerToken });
26+
return (
27+
<SearchBox
28+
dictionaryApi={dictionaryApi}
29+
onSuccess={setResponse}
30+
onError={setErrorMessage}
31+
/>
32+
);
1333
}
1434

1535
export default App;
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { AuthorizationError } from '../../error/authorization';
2+
import { NotFoundError } from '../../error/not-found';
3+
import { InternalServerError } from '../../error/server';
4+
import { DictionaryApi } from './dictionary';
5+
6+
const dictionaryApi = new DictionaryApi({ token: 'bearer-token' });
7+
const searchWord = 'innocuous';
8+
const validResponse = {
9+
dictionaryWord: {
10+
word: searchWord,
11+
lexicalEntry:
12+
'H4sIAAAAAAAEE6WQz0rEMBDGXyXMSWFpFrxIwYP/EEEvepRFsu3YzrbJlPwRl6V3j+rNV/BNFl/KSXfBowcJCZMvXya/fBsInHyFUML56e3Z3fXF1SXMwBmbJXKOq8QpiIQu+jWUG6DB3FCI5JqQt6mD8mEDlYnYcHaAmMUjhd5+fb+67UdXrFKx/QxaTkyqieWsjXEIpdY1VZHYGb8uKmOXnuoGC/aNtliT0eiankKrU/c4eHY6SUV5OqnzSm4+Py7scATjYgZC+gfM+/db0f0fJ+xwSGeQKabdmsPa04wzsGgEMOf0mxChbMHUK5SPP+M+2UkV0/6GOCq2Q48R+7Vqjbc9hqAOTlRlUpCWyvEkH5a5wYvJ3vwM3LNFZVNoPbMNqmfu1MSXydQyRWU8iqKeTBXVwBTYZWZYjHmMPxC0hzQRAgAA',
13+
},
14+
accessSummary: {
15+
totalAccess: 1,
16+
lastAccessAt: '2024-10-28T16:20:42.846Z',
17+
},
18+
};
19+
20+
describe('GET /dictionary/:word', () => {
21+
test('api should return success response', async () => {
22+
global.fetch = jest.fn(
23+
() =>
24+
Promise.resolve({
25+
ok: true,
26+
status: 200,
27+
json: () => Promise.resolve(validResponse),
28+
}) as Promise<Response>,
29+
);
30+
31+
const dictionaryEntry = await dictionaryApi.fetch(searchWord);
32+
33+
expect(dictionaryEntry).not.toBeNull();
34+
expect(dictionaryEntry?.dictionaryWord['name']).toBe('innocuous');
35+
expect(dictionaryEntry?.accessSummary).toMatchObject({
36+
totalAccess: 1,
37+
lastAccessAt: '2024-10-28T16:20:42.846Z',
38+
});
39+
});
40+
41+
test('api should return error if search-word is not found', async () => {
42+
global.fetch = jest.fn(
43+
() =>
44+
Promise.resolve({
45+
ok: false,
46+
status: 404,
47+
json: () => Promise.resolve({}), // Response should not matter
48+
}) as Promise<Response>,
49+
);
50+
51+
await expect(
52+
async () => await dictionaryApi.fetch(searchWord),
53+
).rejects.toThrowError(NotFoundError);
54+
});
55+
56+
test('api should return error any other http status', async () => {
57+
global.fetch = jest.fn(
58+
() =>
59+
Promise.resolve({
60+
ok: false,
61+
status: 500,
62+
json: () => Promise.resolve({}), // Response should not matter
63+
}) as Promise<Response>,
64+
);
65+
66+
await expect(
67+
async () => await dictionaryApi.fetch(searchWord),
68+
).rejects.toThrowError(InternalServerError);
69+
});
70+
71+
test('api should return authorization error if bearer-token is invalid', async () => {
72+
global.fetch = jest.fn(
73+
() =>
74+
Promise.resolve({
75+
ok: false,
76+
status: 401,
77+
json: () => Promise.resolve({}), // Response should not matter
78+
}) as Promise<Response>,
79+
);
80+
81+
await expect(
82+
async () => await dictionaryApi.fetch(searchWord),
83+
).rejects.toThrowError(AuthorizationError);
84+
});
85+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Utils } from '../../lib/utils';
2+
import { DictionaryEntry } from '../../types';
3+
import { AuthorizationError } from '../../error/authorization';
4+
import { InternalServerError } from '../../error/server';
5+
import { NotFoundError } from '../../error/not-found';
6+
7+
export class DictionaryApi {
8+
private token: string;
9+
private utils: Utils;
10+
11+
constructor(options: { token: string }) {
12+
this.token = options.token;
13+
this.utils = new Utils();
14+
}
15+
16+
async fetch(searchWord: string): Promise<DictionaryEntry> {
17+
const response = await fetch(
18+
`${process.env.APP_BASE_URL}/dictionary/${searchWord}`,
19+
{
20+
headers: {
21+
Authorization: `Bearer ${this.token}`,
22+
},
23+
},
24+
);
25+
26+
if (!response.ok) {
27+
if (response.status === 401) {
28+
throw new AuthorizationError();
29+
} else if (response.status === 404) {
30+
throw new NotFoundError();
31+
} else {
32+
throw new InternalServerError();
33+
}
34+
}
35+
36+
const data = await response.json();
37+
data.dictionaryWord = JSON.parse(
38+
this.utils.decodeBase64Gzip(data.dictionaryWord.lexicalEntry),
39+
);
40+
41+
return data as unknown as DictionaryEntry;
42+
}
43+
}

views/interactivity/src/components/search-box/SearchBox.test.tsx

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@ import { render, screen, waitFor } from '@testing-library/react';
33
import '@testing-library/jest-dom';
44
import userEvent from '@testing-library/user-event';
55
import SearchBox from './SearchBox';
6+
import { DictionaryApi } from '../api/dictionary';
7+
8+
const searchWord = 'innocuous';
9+
const validResponse = {
10+
dictionaryWord: {
11+
word: searchWord,
12+
lexicalEntry:
13+
'H4sIAAAAAAAEE6WQz0rEMBDGXyXMSWFpFrxIwYP/EEEvepRFsu3YzrbJlPwRl6V3j+rNV/BNFl/KSXfBowcJCZMvXya/fBsInHyFUML56e3Z3fXF1SXMwBmbJXKOq8QpiIQu+jWUG6DB3FCI5JqQt6mD8mEDlYnYcHaAmMUjhd5+fb+67UdXrFKx/QxaTkyqieWsjXEIpdY1VZHYGb8uKmOXnuoGC/aNtliT0eiankKrU/c4eHY6SUV5OqnzSm4+Py7scATjYgZC+gfM+/db0f0fJ+xwSGeQKabdmsPa04wzsGgEMOf0mxChbMHUK5SPP+M+2UkV0/6GOCq2Q48R+7Vqjbc9hqAOTlRlUpCWyvEkH5a5wYvJ3vwM3LNFZVNoPbMNqmfu1MSXydQyRWU8iqKeTBXVwBTYZWZYjHmMPxC0hzQRAgAA',
14+
},
15+
accessSummary: {
16+
totalAccess: 1,
17+
lastAccessAt: '2024-10-28T16:20:42.846Z',
18+
},
19+
};
20+
const dictionaryApi = new DictionaryApi({ token: 'bearer-token' });
621

722
beforeAll(() => {
823
process.env.APP_BASE_URL = '';
@@ -23,7 +38,13 @@ afterAll(() => {
2338
});
2439

2540
test('search box should contain a text box and a button', async () => {
26-
render(<SearchBox onResponse={jest.fn()} onError={jest.fn()} />);
41+
render(
42+
<SearchBox
43+
dictionaryApi={dictionaryApi}
44+
onSuccess={jest.fn()}
45+
onError={jest.fn()}
46+
/>,
47+
);
2748

2849
const wordInputBox = screen.getByPlaceholderText('Word');
2950
const button = screen.getByRole('button', { name: 'Search' });
@@ -38,20 +59,25 @@ test('on button click, successful api response should returned', async () => {
3859
Promise.resolve({
3960
ok: true,
4061
status: 200,
41-
json: () => Promise.resolve({}),
62+
json: () => Promise.resolve(validResponse),
4263
}) as Promise<Response>,
4364
);
44-
const onResponse = jest.fn();
65+
const onSuccess = jest.fn();
4566
const onError = jest.fn();
46-
const searchWord = 'hello';
4767

48-
render(<SearchBox onResponse={onResponse} onError={onError} />);
68+
render(
69+
<SearchBox
70+
dictionaryApi={dictionaryApi}
71+
onSuccess={onSuccess}
72+
onError={onError}
73+
/>,
74+
);
4975

5076
userEvent.type(screen.getByPlaceholderText('Word'), searchWord);
5177
userEvent.click(screen.getByText('Search'));
5278

5379
await waitFor(() => {
54-
expect(onResponse).toHaveBeenCalledWith({});
80+
expect(onSuccess).toHaveBeenCalledTimes(1);
5581
expect(onError).toHaveBeenCalledTimes(0);
5682
});
5783
});
@@ -65,17 +91,23 @@ test('on button click, error message should return if no word is found', async (
6591
json: () => Promise.resolve({}),
6692
}) as Promise<Response>,
6793
);
68-
const onResponse = jest.fn();
94+
const onSuccess = jest.fn();
6995
const onError = jest.fn();
7096
const searchWord = 'hello';
7197

72-
render(<SearchBox onResponse={onResponse} onError={onError} />);
98+
render(
99+
<SearchBox
100+
dictionaryApi={dictionaryApi}
101+
onSuccess={onSuccess}
102+
onError={onError}
103+
/>,
104+
);
73105

74106
userEvent.type(screen.getByPlaceholderText('Word'), searchWord);
75107
userEvent.click(screen.getByText('Search'));
76108

77109
await waitFor(() => {
78-
expect(onResponse).toHaveBeenCalledTimes(0);
110+
expect(onSuccess).toHaveBeenCalledTimes(0);
79111
expect(onError).toHaveBeenCalledWith(`${searchWord} not found`);
80112
});
81113
});
@@ -89,17 +121,23 @@ test('on button click, error message should return if server returns status code
89121
json: () => Promise.reject(new Error('Internal Server Error')),
90122
}) as Promise<Response>,
91123
);
92-
const onResponse = jest.fn();
124+
const onSuccess = jest.fn();
93125
const onError = jest.fn();
94126
const searchWord = 'hello';
95127

96-
render(<SearchBox onResponse={onResponse} onError={onError} />);
128+
render(
129+
<SearchBox
130+
dictionaryApi={dictionaryApi}
131+
onSuccess={onSuccess}
132+
onError={onError}
133+
/>,
134+
);
97135

98136
userEvent.type(screen.getByPlaceholderText('Word'), searchWord);
99137
userEvent.click(screen.getByText('Search'));
100138

101139
await waitFor(() => {
102-
expect(onResponse).toHaveBeenCalledTimes(0);
140+
expect(onSuccess).toHaveBeenCalledTimes(0);
103141
expect(onError).toHaveBeenCalledWith(
104142
'Something went wrong. Please try again.',
105143
);
@@ -110,17 +148,23 @@ test('on button click, error message should return if exception occurs', async (
110148
global.fetch = jest.fn(() =>
111149
Promise.reject(new Error('Some error occurred')),
112150
);
113-
const onResponse = jest.fn();
151+
const onSuccess = jest.fn();
114152
const onError = jest.fn();
115153
const searchWord = 'hello';
116154

117-
render(<SearchBox onResponse={onResponse} onError={onError} />);
155+
render(
156+
<SearchBox
157+
dictionaryApi={dictionaryApi}
158+
onSuccess={onSuccess}
159+
onError={onError}
160+
/>,
161+
);
118162

119163
userEvent.type(screen.getByPlaceholderText('Word'), searchWord);
120164
userEvent.click(screen.getByText('Search'));
121165

122166
await waitFor(() => {
123-
expect(onResponse).toHaveBeenCalledTimes(0);
167+
expect(onSuccess).toHaveBeenCalledTimes(0);
124168
expect(onError).toHaveBeenCalledWith(
125169
'Something went wrong. Please try again.',
126170
);

views/interactivity/src/components/search-box/SearchBox.tsx

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,36 @@
1-
import React, { useState, useEffect } from 'react';
1+
import React, { useState } from 'react';
22
import { DictionaryEntry } from '../../types';
3+
import { DictionaryApi } from '../api/dictionary';
34
import './SearchBox.css';
5+
import { NotFoundError } from '../../error/not-found';
46

57
type Props = {
6-
onResponse: (response: DictionaryEntry) => void;
8+
dictionaryApi: DictionaryApi;
9+
onSuccess: (response: DictionaryEntry) => void;
710
onError: (errorMessage: string) => void;
811
};
912

10-
export default function SearchBox({ onResponse, onError }: Props) {
11-
const [token, setToken] = useState<string | null>(null);
13+
export default function SearchBox({
14+
dictionaryApi,
15+
onSuccess,
16+
onError,
17+
}: Props) {
1218
const [searchWord, setSearchWord] = useState<string>('');
1319

14-
useEffect(() => {
15-
const token = document.querySelector('meta[name="jwt-token"]');
16-
if (token) {
17-
setToken(token.getAttribute('content'));
18-
}
19-
}, []);
20-
2120
function handleInput(input: string) {
2221
setSearchWord(input);
2322
}
2423

25-
function handleSearch() {
26-
fetch(`${process.env.APP_BASE_URL}/dictionary/${searchWord}`, {
27-
headers: {
28-
Authorization: `Bearer ${token}`,
29-
},
30-
})
31-
.then(async (response) => {
32-
if (!response.ok) {
33-
if (response.status === 404) {
34-
return onError(`${searchWord} not found`);
35-
} else {
36-
return onError('Something went wrong. Please try again.');
37-
}
38-
}
39-
onResponse((await response.json()) as unknown as DictionaryEntry);
40-
})
41-
.catch(() => {
24+
async function handleSearch() {
25+
try {
26+
onSuccess(await dictionaryApi.fetch(searchWord));
27+
} catch (err) {
28+
if (err instanceof NotFoundError) {
29+
onError(`${searchWord} not found`);
30+
} else {
4231
onError('Something went wrong. Please try again.');
43-
});
32+
}
33+
}
4434
}
4535

4636
return (

0 commit comments

Comments
 (0)