Skip to content

Commit 55f4e12

Browse files
authored
feat: Expand utility of rejectNetworkError (#3743)
1 parent cecb937 commit 55f4e12

File tree

6 files changed

+335
-0
lines changed

6 files changed

+335
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"dev": "yarn start",
77
"start": "vite --host",
88
"build": "tsc -b && vite build",
9+
"preview": "vite preview",
910
"test": "vitest run --config ./vitest.config.mjs",
1011
"test:changed": "vitest run --changed --config ./vitest.config.mjs",
1112
"test:watch": "vitest watch --changed --config ./vitest.config.mjs",

src/layouts/shared/NetworkErrorBoundary/NetworkErrorBoundary.jsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import upsideDownUmbrella from './assets/error-upsidedown-umbrella.svg'
1414
import styles from './NetworkErrorBoundary.module.css'
1515

1616
const errorToUI = {
17+
400: {
18+
illustration: upsideDownUmbrella,
19+
title: 'Bad Request',
20+
description: null,
21+
showDocs: true,
22+
},
1723
401: {
1824
illustration: openUmbrella,
1925
title: <a href="/login">Please log in.</a>,

src/layouts/shared/NetworkErrorBoundary/NetworkErrorBoundary.test.jsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,64 @@ describe('NetworkErrorBoundary', () => {
225225
})
226226
})
227227

228+
describe('when the children component has a 400 error', () => {
229+
describe('when not running in self-hosted mode', () => {
230+
it('renders a Bad Request', async () => {
231+
const { user } = setup()
232+
render(<App status={400} />, {
233+
wrapper: wrapper(),
234+
})
235+
236+
const textBox = await screen.findByRole('textbox')
237+
await user.type(textBox, 'fail')
238+
239+
const badRequest = await screen.findByText(/Bad Request/)
240+
expect(badRequest).toBeInTheDocument()
241+
})
242+
243+
it('renders return to previous page button', async () => {
244+
const { user } = setup()
245+
render(<App status={400} />, {
246+
wrapper: wrapper(),
247+
})
248+
249+
const textBox = await screen.findByRole('textbox')
250+
await user.type(textBox, 'fail')
251+
252+
const button = await screen.findByText('Return to previous page')
253+
expect(button).toBeInTheDocument()
254+
})
255+
})
256+
257+
describe('when running in self hosted mode', () => {
258+
it('renders a Bad Request', async () => {
259+
const { user } = setup({ isSelfHosted: true })
260+
render(<App status={400} />, {
261+
wrapper: wrapper(),
262+
})
263+
264+
const textBox = await screen.findByRole('textbox')
265+
await user.type(textBox, 'fail')
266+
267+
const badRequest = await screen.findByText(/Bad Request/)
268+
expect(badRequest).toBeInTheDocument()
269+
})
270+
271+
it('renders return to previous page button', async () => {
272+
const { user } = setup({ isSelfHosted: true })
273+
render(<App status={400} />, {
274+
wrapper: wrapper(),
275+
})
276+
277+
const textBox = await screen.findByRole('textbox')
278+
await user.type(textBox, 'fail')
279+
280+
const button = await screen.findByText('Return to previous page')
281+
expect(button).toBeInTheDocument()
282+
})
283+
})
284+
})
285+
228286
describe('when the children component has a 404 error', () => {
229287
describe('when not running in self-hosted mode', () => {
230288
it('renders a Not found', async () => {
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import {
2+
_rejectNetworkError,
3+
determineSentryLevel,
4+
determineStatusCode,
5+
NetworkErrorName,
6+
NotFoundErrorObject,
7+
OwnerNotActivatedErrorObject,
8+
ParsingErrorObject,
9+
} from './rejectNetworkError'
10+
11+
const mocks = vi.hoisted(() => ({
12+
withScope: vi.fn(),
13+
addBreadcrumb: vi.fn(),
14+
captureMessage: vi.fn(),
15+
setFingerprint: vi.fn(),
16+
setTags: vi.fn(),
17+
setLevel: vi.fn(),
18+
}))
19+
20+
vi.mock('@sentry/react', async () => {
21+
const actual = await vi.importActual('@sentry/react')
22+
return {
23+
...actual,
24+
withScope: mocks.withScope.mockImplementation((fn) =>
25+
fn({
26+
addBreadcrumb: mocks.addBreadcrumb,
27+
setFingerprint: mocks.setFingerprint,
28+
captureMessage: mocks.captureMessage,
29+
setTags: mocks.setTags,
30+
setLevel: mocks.setLevel,
31+
})
32+
),
33+
}
34+
})
35+
36+
afterEach(() => {
37+
vi.clearAllMocks()
38+
})
39+
40+
const parsingError: ParsingErrorObject = {
41+
errorName: 'Parsing Error',
42+
errorDetails: {
43+
error: Error('bad parsing'),
44+
caller: 'TestQueryOpts',
45+
},
46+
}
47+
48+
const notFoundError: NotFoundErrorObject = {
49+
errorName: 'Not Found Error',
50+
errorDetails: {
51+
caller: 'TestQueryOpts',
52+
},
53+
}
54+
55+
const ownerNotActivatedError: OwnerNotActivatedErrorObject = {
56+
errorName: 'Owner Not Activated',
57+
data: { detail: 'test' },
58+
errorDetails: {
59+
caller: 'TestQueryOpts',
60+
},
61+
}
62+
63+
describe('rejectNetworkError', () => {
64+
const testCases = [
65+
{ errorObject: parsingError, level: 'error', status: 400 },
66+
{ errorObject: notFoundError, level: 'info', status: 404 },
67+
{ errorObject: ownerNotActivatedError, level: 'info', status: 403 },
68+
]
69+
70+
describe.each(testCases)(
71+
'when the error is $errorObject.errorName',
72+
({ errorObject, level, status }) => {
73+
it('adds a breadcrumb', () => {
74+
_rejectNetworkError(errorObject).catch((_e) => {})
75+
76+
expect(mocks.addBreadcrumb).toHaveBeenCalledWith({
77+
category: 'network.error',
78+
level,
79+
message: `${errorObject.errorDetails.caller} - ${errorObject.errorName}`,
80+
data:
81+
'error' in errorObject.errorDetails
82+
? errorObject.errorDetails.error
83+
: undefined,
84+
})
85+
})
86+
87+
it('sets the tags', () => {
88+
_rejectNetworkError(errorObject).catch((_e) => {})
89+
90+
expect(mocks.setTags).toHaveBeenCalledWith({
91+
caller: errorObject.errorDetails.caller,
92+
errorName: errorObject.errorName,
93+
})
94+
})
95+
96+
it('sets the level', () => {
97+
_rejectNetworkError(errorObject).catch((_e) => {})
98+
99+
expect(mocks.setLevel).toHaveBeenCalledWith(level)
100+
})
101+
102+
it('sets the fingerprint', () => {
103+
_rejectNetworkError(errorObject).catch((_e) => {
104+
expect(mocks.setFingerprint).toHaveBeenCalledWith([
105+
`${errorObject.errorDetails.caller} - ${errorObject.errorName}`,
106+
])
107+
})
108+
})
109+
110+
it('captures the error with Sentry', () => {
111+
_rejectNetworkError(errorObject).catch((_e) => {})
112+
113+
expect(mocks.captureMessage).toHaveBeenCalledWith(
114+
`${errorObject.errorDetails.caller} - ${errorObject.errorName}`
115+
)
116+
})
117+
118+
it('returns a rejected promise', () => {
119+
const result = _rejectNetworkError(errorObject)
120+
121+
expect(result).rejects.toStrictEqual({
122+
dev: `${errorObject.errorDetails.caller} - ${errorObject.errorName}`,
123+
data: 'data' in errorObject ? errorObject.data : undefined,
124+
status,
125+
})
126+
})
127+
}
128+
)
129+
})
130+
131+
describe('determineSentryLevel', () => {
132+
const testCases = [
133+
{ errorName: 'Parsing Error', level: 'error' },
134+
{ errorName: 'Not Found Error', level: 'info' },
135+
{ errorName: 'Owner Not Activated', level: 'info' },
136+
{ errorName: 'Unknown Error', level: 'error' },
137+
]
138+
139+
describe.each(testCases)(
140+
'when the error is $errorName',
141+
({ errorName, level }) => {
142+
it('returns the correct level', () => {
143+
// casting here to avoid type error
144+
expect(determineSentryLevel(errorName as NetworkErrorName)).toBe(level)
145+
})
146+
}
147+
)
148+
})
149+
150+
describe('determineStatusCode', () => {
151+
const testCases = [
152+
{ errorName: 'Parsing Error', status: 400 },
153+
{ errorName: 'Not Found Error', status: 404 },
154+
{ errorName: 'Owner Not Activated', status: 403 },
155+
{ errorName: 'Unknown Error', status: 400 },
156+
]
157+
158+
describe.each(testCases)(
159+
'when the error is $errorName',
160+
({ errorName, status }) => {
161+
it('returns the correct status code', () => {
162+
// casting here to avoid type error
163+
expect(determineStatusCode(errorName as NetworkErrorName)).toBe(status)
164+
})
165+
}
166+
)
167+
})
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/* eslint-disable camelcase */
2+
import * as Sentry from '@sentry/react'
3+
4+
type ParsingError = 'Parsing Error'
5+
type NotFoundError = 'Not Found Error'
6+
type OwnerNotActivatedError = 'Owner Not Activated'
7+
export type NetworkErrorName =
8+
| ParsingError
9+
| NotFoundError
10+
| OwnerNotActivatedError
11+
12+
export type ParsingErrorObject = {
13+
errorName: ParsingError
14+
errorDetails: { error: Error; caller: string }
15+
}
16+
17+
export type NotFoundErrorObject = {
18+
errorName: NotFoundError
19+
errorDetails: { caller: string }
20+
}
21+
22+
export type OwnerNotActivatedErrorObject = {
23+
errorName: OwnerNotActivatedError
24+
data: { detail?: React.ReactNode }
25+
errorDetails: { caller: string }
26+
}
27+
28+
type NetworkErrorObject =
29+
| ParsingErrorObject
30+
| NotFoundErrorObject
31+
| OwnerNotActivatedErrorObject
32+
33+
/**
34+
* @private exporting for testing - do not use
35+
*/
36+
export function determineSentryLevel(errorName: NetworkErrorName) {
37+
switch (errorName) {
38+
case 'Parsing Error':
39+
return 'error'
40+
case 'Not Found Error':
41+
return 'info'
42+
case 'Owner Not Activated':
43+
return 'info'
44+
default:
45+
return 'error'
46+
}
47+
}
48+
49+
/**
50+
* @private exporting for testing - do not use
51+
*/
52+
export function determineStatusCode(errorName: NetworkErrorName) {
53+
switch (errorName) {
54+
case 'Parsing Error':
55+
return 400
56+
case 'Not Found Error':
57+
return 404
58+
case 'Owner Not Activated':
59+
return 403
60+
default:
61+
return 400
62+
}
63+
}
64+
65+
export function _rejectNetworkError(error: NetworkErrorObject) {
66+
const {
67+
errorName,
68+
errorDetails: { caller },
69+
} = error
70+
71+
const devMsg = `${caller} - ${errorName}`
72+
73+
Sentry.withScope((scope) => {
74+
const level = determineSentryLevel(errorName)
75+
scope.addBreadcrumb({
76+
category: 'network.error',
77+
level: level,
78+
message: devMsg,
79+
data:
80+
'error' in error.errorDetails ? error.errorDetails.error : undefined,
81+
})
82+
83+
scope.setTags({
84+
caller: caller,
85+
errorName: errorName,
86+
})
87+
88+
scope.setLevel(level)
89+
scope.setFingerprint([devMsg])
90+
scope.captureMessage(devMsg)
91+
})
92+
93+
const status = determineStatusCode(errorName)
94+
95+
return Promise.reject({
96+
dev: devMsg,
97+
data: 'data' in error ? error.data : undefined,
98+
status: status,
99+
})
100+
}

vite.config.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ export default defineConfig((config) => {
6060
server: {
6161
port: 3000,
6262
},
63+
preview: {
64+
port: 3000,
65+
},
6366
build: {
6467
outDir: 'build',
6568
sourcemap: runSentryPlugin,

0 commit comments

Comments
 (0)