Skip to content

Commit 9936f6b

Browse files
authored
Merge pull request #811 from bcgsc/feat/DEVSU-2982-404-render-test
[DEVSU-2982] 404 render test addon
2 parents 7cdc7dd + ddf1b16 commit 9936f6b

53 files changed

Lines changed: 2325 additions & 233 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.eslintrc.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,22 @@ module.exports = {
3838
},
3939
overrides: [
4040
{
41+
// Build/test tooling that tsconfig.json deliberately does not include.
42+
// Type-aware linting needs the file to be in the project, so these are
43+
// linted with the type-checked rules (and parserOptions.project) off.
4144
extends: ['plugin:@typescript-eslint/disable-type-checked'],
42-
files: ['./*.js'],
45+
files: ['./*.js', './config/jest/**/*.js'],
46+
env: {
47+
jest: true,
48+
node: true,
49+
},
50+
rules: {
51+
// These files are CommonJS by necessity: webpack and jest load them outside the app's module pipeline
52+
'@typescript-eslint/no-var-requires': 'off',
53+
'import/no-extraneous-dependencies': 'off',
54+
// Setup files legitimately declare several small mock classes
55+
'max-classes-per-file': 'off',
56+
},
4357
},
4458
],
4559
};
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { renderHook } from '@testing-library/react';
2+
3+
import snackbar from '@/services/SnackbarUtils';
4+
import { makeApiError } from '@/test/apiErrorHelpers';
5+
import useApiError, { getStatus } from '../useApiError';
6+
7+
jest.mock('@/services/SnackbarUtils');
8+
9+
describe('useApiError', () => {
10+
beforeEach(() => {
11+
jest.clearAllMocks();
12+
jest.spyOn(console, 'error').mockImplementation(() => {});
13+
});
14+
15+
afterEach(() => {
16+
(console.error as jest.Mock).mockRestore();
17+
});
18+
19+
describe('getStatus', () => {
20+
test('reads the status attached by errorHandler', () => {
21+
expect(getStatus(makeApiError(404))).toBe(404);
22+
expect(getStatus(makeApiError(500, 'Server Error'))).toBe(500);
23+
});
24+
25+
test('is undefined for errors without a response', () => {
26+
expect(getStatus(new Error('network down'))).toBeUndefined();
27+
expect(getStatus(undefined)).toBeUndefined();
28+
});
29+
});
30+
31+
describe('in the browser view', () => {
32+
test('reportError shows a labelled snackbar', () => {
33+
const { result } = renderHook(() => useApiError(false));
34+
35+
result.current.reportError('Failed to load comparators', makeApiError(404));
36+
37+
expect(snackbar.error).toHaveBeenCalledTimes(1);
38+
expect(snackbar.error).toHaveBeenCalledWith('Failed to load comparators: Not Found');
39+
});
40+
41+
test('reportErrorSkip404 logs but shows no snackbar on 404', () => {
42+
const { result } = renderHook(() => useApiError(false));
43+
44+
result.current.reportErrorSkip404('Failed to load TMB', makeApiError(404));
45+
46+
expect(snackbar.error).not.toHaveBeenCalled();
47+
expect(console.error).toHaveBeenCalled();
48+
});
49+
50+
test('reportErrorSkip404 still reports other statuses', () => {
51+
const { result } = renderHook(() => useApiError(false));
52+
53+
result.current.reportErrorSkip404('Failed to load TMB', makeApiError(500, 'Server Error'));
54+
55+
expect(snackbar.error).toHaveBeenCalledWith('Failed to load TMB: Server Error');
56+
});
57+
58+
test('queryOnError returns a handler usable as a react-query onError', () => {
59+
const { result } = renderHook(() => useApiError(false));
60+
61+
result.current.queryOnError('Failed to load MSI')(makeApiError(404));
62+
63+
expect(snackbar.error).toHaveBeenCalledWith('Failed to load MSI: Not Found');
64+
});
65+
});
66+
67+
describe('in the print view', () => {
68+
test('reportError logs but shows no snackbar', () => {
69+
const { result } = renderHook(() => useApiError(true));
70+
71+
result.current.reportError('Failed to load comparators', makeApiError(404));
72+
73+
expect(snackbar.error).not.toHaveBeenCalled();
74+
expect(console.error).toHaveBeenCalled();
75+
});
76+
77+
test('queryOnError shows no snackbar', () => {
78+
const { result } = renderHook(() => useApiError(true));
79+
80+
result.current.queryOnError('Failed to load MSI')(makeApiError(500, 'Server Error'));
81+
82+
expect(snackbar.error).not.toHaveBeenCalled();
83+
});
84+
});
85+
86+
test('handlers are stable across re-renders with the same isPrint', () => {
87+
const { result, rerender } = renderHook(({ isPrint }) => useApiError(isPrint), {
88+
initialProps: { isPrint: false },
89+
});
90+
const first = result.current;
91+
92+
rerender({ isPrint: false });
93+
94+
expect(result.current.reportError).toBe(first.reportError);
95+
expect(result.current.queryOnError).toBe(first.queryOnError);
96+
});
97+
});

app/hooks/useApiError.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { useCallback, useMemo } from 'react';
2+
3+
import snackbar from '@/services/SnackbarUtils';
4+
import { ErrorMixin } from '@/services/errors/errors';
5+
6+
type ApiError = Error | ErrorMixin;
7+
8+
/**
9+
* Status code attached by `errorHandler` when the server responded with a
10+
* non-2xx code. Absent for network/parse failures.
11+
*/
12+
const getStatus = (err: unknown): number | undefined => (
13+
(err as ErrorMixin)?.content?.status as number | undefined
14+
);
15+
16+
const getMessage = (err: unknown): string => {
17+
if (err instanceof Error) {
18+
return err.message;
19+
}
20+
return String(err);
21+
};
22+
23+
type UseApiErrorReturnType = {
24+
/**
25+
* For imperative `try`/`catch` blocks. Logs always; shows a snackbar only
26+
* outside of print.
27+
*/
28+
reportError: (label: string, err: unknown) => void;
29+
/** As `reportError`, but stays silent for 404s. */
30+
reportErrorSkip404: (label: string, err: unknown) => void;
31+
/** `onError` handler for react-query options. */
32+
queryOnError: (label: string) => (err: ApiError) => void;
33+
/** As `queryOnError`, but stays silent for 404s. */
34+
queryOnErrorSkip404: (label: string) => (err: ApiError) => void;
35+
};
36+
37+
/**
38+
* Consistent API failure reporting for report sections.
39+
*
40+
* A section must keep rendering when one of its requests fails, so every
41+
* consumer of this hook is expected to tolerate missing data rather than bail
42+
* out. The hook only decides how the failure is surfaced:
43+
*
44+
* - the browser view gets a labelled error snackbar so the user knows which
45+
* part of the page is incomplete
46+
* - the print view gets a console error only, since a snackbar would be
47+
* captured in the printed output and there is nobody to dismiss it
48+
*
49+
* 404 is treated as "this resource does not exist for this report", which is a
50+
* normal state for many optional sections, hence the `Skip404` variants.
51+
*
52+
* @param isPrint true when rendering inside PrintView
53+
*/
54+
const useApiError = (isPrint = false): UseApiErrorReturnType => {
55+
const reportError = useCallback((label: string, err: unknown) => {
56+
console.error(label, err);
57+
if (!isPrint) {
58+
snackbar.error(`${label}: ${getMessage(err)}`);
59+
}
60+
}, [isPrint]);
61+
62+
const reportErrorSkip404 = useCallback((label: string, err: unknown) => {
63+
if (getStatus(err) === 404) {
64+
// Still logged so the absence is traceable, but not worth interrupting
65+
// the user over: the resource simply does not exist for this report
66+
console.error(label, err);
67+
return;
68+
}
69+
reportError(label, err);
70+
}, [reportError]);
71+
72+
const queryOnError = useCallback((label: string) => (
73+
(err: ApiError) => reportError(label, err)
74+
), [reportError]);
75+
76+
const queryOnErrorSkip404 = useCallback((label: string) => (
77+
(err: ApiError) => reportErrorSkip404(label, err)
78+
), [reportErrorSkip404]);
79+
80+
return useMemo(() => ({
81+
reportError,
82+
reportErrorSkip404,
83+
queryOnError,
84+
queryOnErrorSkip404,
85+
}), [reportError, reportErrorSkip404, queryOnError, queryOnErrorSkip404]);
86+
};
87+
88+
export type { UseApiErrorReturnType };
89+
export { getStatus };
90+
export default useApiError;

app/test/apiErrorHelpers.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { APIConnectionFailureError } from '@/services/errors/errors';
2+
3+
/**
4+
* Builds the error a component actually receives from `api.get(...).request()`
5+
* when the server responds with a non-2xx code.
6+
*
7+
* `errorHandler` maps 404 onto `APIConnectionFailureError` and attaches the
8+
* status under `content.status`, which is what `useApiError`'s Skip404
9+
* variants and the react-query `retry` predicate both key off. Constructing a
10+
* plain `Error` in a test would therefore exercise a different code path.
11+
*
12+
* @param status HTTP status code, defaults to 404
13+
* @param message server supplied message
14+
*/
15+
const makeApiError = (status = 404, message = 'Not Found'): Error => {
16+
const err = new APIConnectionFailureError({ message, status });
17+
return err as unknown as Error;
18+
};
19+
20+
/**
21+
* `ApiCallSet` mock factory. `request(settled)` mirrors the real class:
22+
* `Promise.all` semantics when called without arguments, `Promise.allSettled`
23+
* results when called with `true`.
24+
*
25+
* @param outcomes one entry per call in the set, in order. An `Error` value
26+
* marks that call as rejected, anything else as resolved with that value.
27+
*/
28+
const mockApiCallSet = (outcomes: unknown[]) => () => ({
29+
request: jest.fn(async (settled = false) => {
30+
if (settled) {
31+
return outcomes.map((outcome) => (
32+
outcome instanceof Error
33+
? { status: 'rejected' as const, reason: outcome }
34+
: { status: 'fulfilled' as const, value: outcome }
35+
));
36+
}
37+
const rejected = outcomes.find((outcome) => outcome instanceof Error);
38+
if (rejected) {
39+
throw rejected;
40+
}
41+
return outcomes;
42+
}),
43+
abort: jest.fn(),
44+
});
45+
46+
/**
47+
* Marks the call at `index` as a 404 and resolves every other call in the set
48+
* with the matching entry of `resolved`.
49+
*
50+
* @param resolved the happy path values for the whole set
51+
* @param index which call should 404
52+
*/
53+
const mockApiCallSetWith404At = (resolved: unknown[], index: number) => mockApiCallSet(
54+
resolved.map((value, i) => (i === index ? makeApiError() : value)),
55+
);
56+
57+
export { makeApiError, mockApiCallSet, mockApiCallSetWith404At };
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { makeApiError } from '@/test/apiErrorHelpers';
2+
import { unwrapSettled } from '../settleApiCalls';
3+
4+
describe('unwrapSettled', () => {
5+
const settle = (outcomes: unknown[]): PromiseSettledResult<unknown>[] => outcomes.map(
6+
(outcome) => (outcome instanceof Error
7+
? { status: 'rejected' as const, reason: outcome }
8+
: { status: 'fulfilled' as const, value: outcome }),
9+
);
10+
11+
test('returns every value positionally when all calls resolve', () => {
12+
const onError = jest.fn();
13+
14+
const result = unwrapSettled(settle([['a'], ['b'], ['c']]), ['a', 'b', 'c'], onError);
15+
16+
expect(result).toEqual([['a'], ['b'], ['c']]);
17+
expect(onError).not.toHaveBeenCalled();
18+
});
19+
20+
test('keeps the resolved values when one call 404s', () => {
21+
const onError = jest.fn();
22+
const err = makeApiError(404);
23+
24+
const result = unwrapSettled(
25+
settle([['variants'], err, ['images']]),
26+
['load variants', 'load circos', 'load images'],
27+
onError,
28+
);
29+
30+
expect(result).toEqual([['variants'], undefined, ['images']]);
31+
expect(onError).toHaveBeenCalledTimes(1);
32+
expect(onError).toHaveBeenCalledWith('load circos', err);
33+
});
34+
35+
test('reports each failure separately', () => {
36+
const onError = jest.fn();
37+
38+
unwrapSettled(
39+
settle([makeApiError(404), ['ok'], makeApiError(500, 'Server Error')]),
40+
['first', 'second', 'third'],
41+
onError,
42+
);
43+
44+
expect(onError).toHaveBeenCalledTimes(2);
45+
expect(onError.mock.calls.map(([label]) => label)).toEqual(['first', 'third']);
46+
});
47+
48+
test('falls back to a positional label when labels are missing', () => {
49+
const onError = jest.fn();
50+
51+
unwrapSettled(settle([makeApiError(404)]), [], onError);
52+
53+
expect(onError).toHaveBeenCalledWith('request 1', expect.any(Error));
54+
});
55+
});

app/utils/settleApiCalls.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Helpers for working with `ApiCallSet.request(true)`, which resolves to
3+
* `Promise.allSettled` results so that one failing call (e.g. a 404 for a
4+
* resource that simply does not exist on this report) does not discard the
5+
* responses of every other call in the set.
6+
*/
7+
8+
type SettleErrorHandler = (label: string, err: unknown) => void;
9+
10+
/**
11+
* Maps settled results back onto a positional tuple, substituting `undefined`
12+
* for any call that rejected and reporting it through `onError`.
13+
*
14+
* @param results settled results, in the same order the calls were pushed
15+
* @param labels human readable name per call, used in the error message
16+
* @param onError invoked once per rejected call
17+
*/
18+
const unwrapSettled = <T extends unknown[]>(
19+
results: PromiseSettledResult<unknown>[],
20+
labels: string[],
21+
onError: SettleErrorHandler,
22+
): Partial<T> => results.map((result, index) => {
23+
if (result.status === 'fulfilled') {
24+
return result.value;
25+
}
26+
onError(labels[index] ?? `request ${index + 1}`, result.reason);
27+
return undefined;
28+
}) as Partial<T>;
29+
30+
export type { SettleErrorHandler };
31+
export { unwrapSettled };
32+
export default unwrapSettled;

0 commit comments

Comments
 (0)