From 9ed5df2e45e8d368d3d4df1c992a0901a512ff6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20=C3=96brink?= Date: Mon, 8 Feb 2021 17:27:51 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20API=20call=20retries=20a?= =?UTF-8?q?nd=20support=20for=20error=20reporting=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 API call retries and support for error reporting * docs: ✏️ Added reporter to README --- .eslintrc.js | 2 +- README.md | 7 +++- src/__mocks__/reporter.js | 6 +++ src/hooks.ts | 10 ++++- src/middleware.ts | 50 +++++++++++++++++----- src/provider.tsx | 17 ++++++-- src/reducers.ts | 8 ++++ src/types.ts | 31 ++++++++++---- src/useCalendar.test.js | 81 +++++++++++++++++++++++++++++++++++- src/useChildlist.test.js | 81 +++++++++++++++++++++++++++++++++++- src/useClassmates.test.js | 81 +++++++++++++++++++++++++++++++++++- src/useMenu.test.js | 81 +++++++++++++++++++++++++++++++++++- src/useNews.test.js | 81 +++++++++++++++++++++++++++++++++++- src/useNotifications.test.js | 81 +++++++++++++++++++++++++++++++++++- src/useSchedule.test.js | 81 +++++++++++++++++++++++++++++++++++- src/useUser.test.js | 81 +++++++++++++++++++++++++++++++++++- 16 files changed, 746 insertions(+), 33 deletions(-) create mode 100644 src/__mocks__/reporter.js diff --git a/.eslintrc.js b/.eslintrc.js index 23a8334d9..a9c0ae87e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,6 +10,6 @@ module.exports = { '@typescript-eslint/semi': ['error', 'never'], 'jest/no-mocks-import': [0], 'max-len': [1, 110], - 'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx'] }], + 'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx', '.tsx'] }], }, } diff --git a/README.md b/README.md index 871b3d9c2..f87dfe294 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,16 @@ import init from '@skolplattformen/embedded-api' import { CookieManager } from '@react-native-community/cookies' import AsyncStorage from '@react-native-async-storage/async-storage' import { RootComponent } from './components/root' +import crashlytics from '@react-native-firebase/crashlytics' const api = init(fetch, () => CookieManager.clearAll()) +const reporter = { + log: (message) => crashlytics().log(message), + error: (error, label) => crashlytics().recordError(error, label), +} export default () => ( - + ) diff --git a/src/__mocks__/reporter.js b/src/__mocks__/reporter.js new file mode 100644 index 000000000..ae27bbdad --- /dev/null +++ b/src/__mocks__/reporter.js @@ -0,0 +1,6 @@ +const reporter = { + log: jest.fn().mockName('log'), + error: jest.fn().mockName('error'), +} + +export default reporter diff --git a/src/hooks.ts b/src/hooks.ts index 4868f3713..15901a08a 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -39,7 +39,9 @@ const hook = ( const state = stateMap[key] || { status: 'pending', data: defaultValue } return state } - const { api, storage, isLoggedIn } = useApi() + const { + api, isLoggedIn, reporter, storage, + } = useApi() const initialState = select(store.getState() as EntityStoreRootState) const [state, setState] = useState(initialState) const dispatch = useDispatch() @@ -50,6 +52,7 @@ const hook = ( key, defaultValue, apiCall: apiCaller(api), + retries: 0, } // Only use cache when not in fake mode @@ -72,6 +75,11 @@ const hook = ( || newState.data !== state.data || newState.error !== state.error) { setState(newState) + + if (newState.error) { + const description = `Error getting ${entityName} from API` + reporter.error(newState.error, description) + } } } useEffect(() => store.subscribe(listener), []) diff --git a/src/middleware.ts b/src/middleware.ts index 3bfefbd38..efd145903 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,15 +1,25 @@ /* eslint-disable default-case */ import { Middleware } from 'redux' -import { EntityAction, EntityStoreRootState } from './types' +import { EntityAction, EntityStoreRootState, ExtraActionProps } from './types' type IMiddleware = Middleware<{}, EntityStoreRootState> export const apiMiddleware: IMiddleware = (storeApi) => (next) => (action: EntityAction) => { try { switch (action.type) { case 'GET_FROM_API': { + // Retrieve from cache + if (action.extra?.getFromCache) { + const cacheAction: EntityAction = { + ...action, + type: 'GET_FROM_CACHE', + } + storeApi.dispatch(cacheAction) + } + // Call api const apiCall = action.extra?.apiCall if (apiCall) { + const extra = action.extra as ExtraActionProps apiCall() .then((res: any) => { const resultAction: EntityAction = { @@ -19,7 +29,7 @@ export const apiMiddleware: IMiddleware = (storeApi) => (next) => (action: Entit } storeApi.dispatch(resultAction) - if (action.extra?.saveToCache && res) { + if (extra.saveToCache && res) { const cacheAction: EntityAction = { ...resultAction, type: 'STORE_IN_CACHE', @@ -27,15 +37,35 @@ export const apiMiddleware: IMiddleware = (storeApi) => (next) => (action: Entit storeApi.dispatch(cacheAction) } }) - } + .catch((error) => { + const retries = extra.retries + 1 - // Retrieve from cache - if (action.extra?.getFromCache) { - const cacheAction: EntityAction = { - ...action, - type: 'GET_FROM_CACHE', - } - storeApi.dispatch(cacheAction) + const errorAction: EntityAction = { + ...action, + extra: { + ...extra, + retries, + }, + type: 'API_ERROR', + error, + } + storeApi.dispatch(errorAction) + + // Retry 3 times + if (retries < 3) { + const retryAction: EntityAction = { + ...action, + type: 'GET_FROM_API', + extra: { + ...extra, + retries, + }, + } + setTimeout(() => { + storeApi.dispatch(retryAction) + }, retries * 500) + } + }) } } } diff --git a/src/provider.tsx b/src/provider.tsx index c6033a68b..d6ffda4f5 100644 --- a/src/provider.tsx +++ b/src/provider.tsx @@ -7,10 +7,20 @@ import React, { import { Provider } from 'react-redux' import { ApiContext } from './context' import store from './store' -import { AsyncStorage, IApiContext } from './types' +import { AsyncStorage, IApiContext, Reporter } from './types' -type TApiProvider = FC> -export const ApiProvider: TApiProvider = ({ children, api, storage }) => { +type TApiProvider = FC> +const noopReporter: Reporter = { + log: () => { }, + error: () => { }, +} +export const ApiProvider: TApiProvider = ({ + children, api, storage, reporter = noopReporter, +}) => { const [isLoggedIn, setIsLoggedIn] = useState(api.isLoggedIn) const [isFake, setIsFake] = useState(api.isFake) @@ -19,6 +29,7 @@ export const ApiProvider: TApiProvider = ({ children, api, storage }) => { storage, isLoggedIn, isFake, + reporter, } useEffect(() => { diff --git a/src/reducers.ts b/src/reducers.ts index 8d3c378b2..70385a9ca 100644 --- a/src/reducers.ts +++ b/src/reducers.ts @@ -37,6 +37,14 @@ const createReducer = (entity: EntityName): EntityReducer => { } break } + case 'API_ERROR': { + newNode = { + ...node, + status: action.extra.retries < 3 ? node.status : 'error', + error: action.error, + } + break + } case 'RESULT_FROM_CACHE': { newNode = { ...node, diff --git a/src/types.ts b/src/types.ts index 062b8f09d..498d4cada 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,11 +11,17 @@ import { } from '@skolplattformen/embedded-api' import { Action, Reducer } from 'redux' +export interface Reporter { + log: (message: string) => void + error: (error: Error, label?: string) => void +} + export interface IApiContext { api: Api storage: AsyncStorage isLoggedIn: boolean isFake: boolean + reporter: Reporter } export type EntityStatus = 'pending' | 'loading' | 'loaded' | 'error' @@ -30,21 +36,28 @@ export interface ApiCall { } export interface ExtraActionProps { apiCall: ApiCall + retries: number key: string defaultValue: T getFromCache?: () => Promise saveToCache?: (value: string) => Promise } -export type EntityActionType = 'GET_FROM_API' | 'RESULT_FROM_API' | 'GET_FROM_CACHE' | 'RESULT_FROM_CACHE' | 'STORE_IN_CACHE' | 'CLEAR' +export type EntityActionType = 'GET_FROM_API' + | 'RESULT_FROM_API' + | 'API_ERROR' + | 'GET_FROM_CACHE' + | 'RESULT_FROM_CACHE' + | 'STORE_IN_CACHE' + | 'CLEAR' export type EntityName = 'USER' -| 'CHILDREN' -| 'CALENDAR' -| 'CLASSMATES' -| 'MENU' -| 'NEWS' -| 'NOTIFICATIONS' -| 'SCHEDULE' -| 'ALL' + | 'CHILDREN' + | 'CALENDAR' + | 'CLASSMATES' + | 'MENU' + | 'NEWS' + | 'NOTIFICATIONS' + | 'SCHEDULE' + | 'ALL' export interface EntityAction extends Action { entity: EntityName data?: T diff --git a/src/useCalendar.test.js b/src/useCalendar.test.js index bacf6b1e7..40bbe3c78 100644 --- a/src/useCalendar.test.js +++ b/src/useCalendar.test.js @@ -5,6 +5,7 @@ import { useCalendar } from './hooks' import store from './store' import init from './__mocks__/@skolplattformen/embedded-api' import createStorage from './__mocks__/AsyncStorage' +import reporter from './__mocks__/reporter' const pause = (ms = 0) => new Promise((r) => setTimeout(r, ms)) @@ -14,7 +15,13 @@ describe('useCalendar(child)', () => { let response let child const wrapper = ({ children }) => ( - {children} + + {children} + ) beforeEach(() => { response = [{ id: 1 }] @@ -144,4 +151,76 @@ describe('useCalendar(child)', () => { expect(storage.cache.calendar_10).toEqual('[{"id":2}]') }) }) + it('retries if api fails', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getCalendar.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useCalendar(child), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('loading') + expect(result.current.data).toEqual([{ id: 2 }]) + + jest.advanceTimersToNextTimer() + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.status).toEqual('loaded') + expect(result.current.data).toEqual([{ id: 1 }]) + }) + }) + it('gives up after 3 retries', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getCalendar.mockRejectedValueOnce(error) + api.getCalendar.mockRejectedValueOnce(error) + api.getCalendar.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useCalendar(child), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('loading') + expect(result.current.data).toEqual([{ id: 2 }]) + + jest.advanceTimersToNextTimer() + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('error') + expect(result.current.data).toEqual([{ id: 2 }]) + }) + }) + it('reports if api fails', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getCalendar.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useCalendar(child), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + + expect(reporter.error).toHaveBeenCalledWith(error, 'Error getting CALENDAR from API') + }) + }) }) diff --git a/src/useChildlist.test.js b/src/useChildlist.test.js index 523f20076..2c8a73469 100644 --- a/src/useChildlist.test.js +++ b/src/useChildlist.test.js @@ -5,6 +5,7 @@ import { useChildList } from './hooks' import store from './store' import init from './__mocks__/@skolplattformen/embedded-api' import createStorage from './__mocks__/AsyncStorage' +import reporter from './__mocks__/reporter' const pause = (ms = 0) => new Promise((r) => setTimeout(r, ms)) @@ -13,7 +14,13 @@ describe('useChildList()', () => { let storage let response const wrapper = ({ children }) => ( - {children} + + {children} + ) beforeEach(() => { response = [{ id: 1 }] @@ -130,4 +137,76 @@ describe('useChildList()', () => { expect(storage.cache.children).toEqual('[{"id":2}]') }) }) + it('retries if api fails', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getChildren.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useChildList(), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('loading') + expect(result.current.data).toEqual([{ id: 2 }]) + + jest.advanceTimersToNextTimer() + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.status).toEqual('loaded') + expect(result.current.data).toEqual([{ id: 1 }]) + }) + }) + it('gives up after 3 retries', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getChildren.mockRejectedValueOnce(error) + api.getChildren.mockRejectedValueOnce(error) + api.getChildren.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useChildList(), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('loading') + expect(result.current.data).toEqual([{ id: 2 }]) + + jest.advanceTimersToNextTimer() + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('error') + expect(result.current.data).toEqual([{ id: 2 }]) + }) + }) + it('reports if api fails', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getChildren.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useChildList(), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + + expect(reporter.error).toHaveBeenCalledWith(error, 'Error getting CHILDREN from API') + }) + }) }) diff --git a/src/useClassmates.test.js b/src/useClassmates.test.js index 0eb30e725..b5601f3af 100644 --- a/src/useClassmates.test.js +++ b/src/useClassmates.test.js @@ -5,6 +5,7 @@ import { useClassmates } from './hooks' import store from './store' import init from './__mocks__/@skolplattformen/embedded-api' import createStorage from './__mocks__/AsyncStorage' +import reporter from './__mocks__/reporter' const pause = (ms = 0) => new Promise((r) => setTimeout(r, ms)) @@ -14,7 +15,13 @@ describe('useClassmates(child)', () => { let response let child const wrapper = ({ children }) => ( - {children} + + {children} + ) beforeEach(() => { response = [{ id: 1 }] @@ -132,4 +139,76 @@ describe('useClassmates(child)', () => { expect(storage.cache.classmates_10).toEqual('[{"id":2}]') }) }) + it('retries if api fails', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getClassmates.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useClassmates(child), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('loading') + expect(result.current.data).toEqual([{ id: 2 }]) + + jest.advanceTimersToNextTimer() + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.status).toEqual('loaded') + expect(result.current.data).toEqual([{ id: 1 }]) + }) + }) + it('gives up after 3 retries', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getClassmates.mockRejectedValueOnce(error) + api.getClassmates.mockRejectedValueOnce(error) + api.getClassmates.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useClassmates(child), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('loading') + expect(result.current.data).toEqual([{ id: 2 }]) + + jest.advanceTimersToNextTimer() + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('error') + expect(result.current.data).toEqual([{ id: 2 }]) + }) + }) + it('reports if api fails', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getClassmates.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useClassmates(child), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + + expect(reporter.error).toHaveBeenCalledWith(error, 'Error getting CLASSMATES from API') + }) + }) }) diff --git a/src/useMenu.test.js b/src/useMenu.test.js index bda6f15ae..f52fb09b4 100644 --- a/src/useMenu.test.js +++ b/src/useMenu.test.js @@ -5,6 +5,7 @@ import { useMenu } from './hooks' import store from './store' import init from './__mocks__/@skolplattformen/embedded-api' import createStorage from './__mocks__/AsyncStorage' +import reporter from './__mocks__/reporter' const pause = (ms = 0) => new Promise((r) => setTimeout(r, ms)) @@ -14,7 +15,13 @@ describe('useMenu(child)', () => { let response let child const wrapper = ({ children }) => ( - {children} + + {children} + ) beforeEach(() => { response = [{ id: 1 }] @@ -132,4 +139,76 @@ describe('useMenu(child)', () => { expect(storage.cache.menu_10).toEqual('[{"id":2}]') }) }) + it('retries if api fails', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getMenu.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useMenu(child), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('loading') + expect(result.current.data).toEqual([{ id: 2 }]) + + jest.advanceTimersToNextTimer() + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.status).toEqual('loaded') + expect(result.current.data).toEqual([{ id: 1 }]) + }) + }) + it('gives up after 3 retries', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getMenu.mockRejectedValueOnce(error) + api.getMenu.mockRejectedValueOnce(error) + api.getMenu.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useMenu(child), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('loading') + expect(result.current.data).toEqual([{ id: 2 }]) + + jest.advanceTimersToNextTimer() + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('error') + expect(result.current.data).toEqual([{ id: 2 }]) + }) + }) + it('reports if api fails', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getMenu.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useMenu(child), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + + expect(reporter.error).toHaveBeenCalledWith(error, 'Error getting MENU from API') + }) + }) }) diff --git a/src/useNews.test.js b/src/useNews.test.js index f89de8382..bf551e6a1 100644 --- a/src/useNews.test.js +++ b/src/useNews.test.js @@ -5,6 +5,7 @@ import { useNews } from './hooks' import store from './store' import init from './__mocks__/@skolplattformen/embedded-api' import createStorage from './__mocks__/AsyncStorage' +import reporter from './__mocks__/reporter' const pause = (ms = 0) => new Promise((r) => setTimeout(r, ms)) @@ -14,7 +15,13 @@ describe('useNews(child)', () => { let response let child const wrapper = ({ children }) => ( - {children} + + {children} + ) beforeEach(() => { response = [{ id: 1 }] @@ -132,4 +139,76 @@ describe('useNews(child)', () => { expect(storage.cache.news_10).toEqual('[{"id":2}]') }) }) + it('retries if api fails', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getNews.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useNews(child), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('loading') + expect(result.current.data).toEqual([{ id: 2 }]) + + jest.advanceTimersToNextTimer() + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.status).toEqual('loaded') + expect(result.current.data).toEqual([{ id: 1 }]) + }) + }) + it('gives up after 3 retries', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getNews.mockRejectedValueOnce(error) + api.getNews.mockRejectedValueOnce(error) + api.getNews.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useNews(child), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('loading') + expect(result.current.data).toEqual([{ id: 2 }]) + + jest.advanceTimersToNextTimer() + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('error') + expect(result.current.data).toEqual([{ id: 2 }]) + }) + }) + it('reports if api fails', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getNews.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useNews(child), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + + expect(reporter.error).toHaveBeenCalledWith(error, 'Error getting NEWS from API') + }) + }) }) diff --git a/src/useNotifications.test.js b/src/useNotifications.test.js index 1a4ae3833..aa9f28475 100644 --- a/src/useNotifications.test.js +++ b/src/useNotifications.test.js @@ -5,6 +5,7 @@ import { useNotifications } from './hooks' import store from './store' import init from './__mocks__/@skolplattformen/embedded-api' import createStorage from './__mocks__/AsyncStorage' +import reporter from './__mocks__/reporter' const pause = (ms = 0) => new Promise((r) => setTimeout(r, ms)) @@ -14,7 +15,13 @@ describe('useNotifications(child)', () => { let response let child const wrapper = ({ children }) => ( - {children} + + {children} + ) beforeEach(() => { response = [{ id: 1 }] @@ -132,4 +139,76 @@ describe('useNotifications(child)', () => { expect(storage.cache.notifications_10).toEqual('[{"id":2}]') }) }) + it('retries if api fails', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getNotifications.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useNotifications(child), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('loading') + expect(result.current.data).toEqual([{ id: 2 }]) + + jest.advanceTimersToNextTimer() + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.status).toEqual('loaded') + expect(result.current.data).toEqual([{ id: 1 }]) + }) + }) + it('gives up after 3 retries', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getNotifications.mockRejectedValueOnce(error) + api.getNotifications.mockRejectedValueOnce(error) + api.getNotifications.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useNotifications(child), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('loading') + expect(result.current.data).toEqual([{ id: 2 }]) + + jest.advanceTimersToNextTimer() + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('error') + expect(result.current.data).toEqual([{ id: 2 }]) + }) + }) + it('reports if api fails', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getNotifications.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useNotifications(child), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + + expect(reporter.error).toHaveBeenCalledWith(error, 'Error getting NOTIFICATIONS from API') + }) + }) }) diff --git a/src/useSchedule.test.js b/src/useSchedule.test.js index 15d64626b..d44f3fa83 100644 --- a/src/useSchedule.test.js +++ b/src/useSchedule.test.js @@ -5,6 +5,7 @@ import { useSchedule } from './hooks' import store from './store' import init from './__mocks__/@skolplattformen/embedded-api' import createStorage from './__mocks__/AsyncStorage' +import reporter from './__mocks__/reporter' const pause = (ms = 0) => new Promise((r) => setTimeout(r, ms)) @@ -16,7 +17,13 @@ describe('useSchedule(child, from, to)', () => { let from let to const wrapper = ({ children }) => ( - {children} + + {children} + ) beforeEach(() => { response = [{ id: 1 }] @@ -136,4 +143,76 @@ describe('useSchedule(child, from, to)', () => { expect(storage.cache['schedule_10_2021-01-01_2021-01-08']).toEqual('[{"id":2}]') }) }) + it('retries if api fails', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getSchedule.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useSchedule(child, from, to), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('loading') + expect(result.current.data).toEqual([{ id: 2 }]) + + jest.advanceTimersToNextTimer() + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.status).toEqual('loaded') + expect(result.current.data).toEqual([{ id: 1 }]) + }) + }) + it('gives up after 3 retries', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getSchedule.mockRejectedValueOnce(error) + api.getSchedule.mockRejectedValueOnce(error) + api.getSchedule.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useSchedule(child, from, to), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('loading') + expect(result.current.data).toEqual([{ id: 2 }]) + + jest.advanceTimersToNextTimer() + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('error') + expect(result.current.data).toEqual([{ id: 2 }]) + }) + }) + it('reports if api fails', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getSchedule.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useSchedule(child, from, to), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + + expect(reporter.error).toHaveBeenCalledWith(error, 'Error getting SCHEDULE from API') + }) + }) }) diff --git a/src/useUser.test.js b/src/useUser.test.js index 2bc72eab8..19e9ade53 100644 --- a/src/useUser.test.js +++ b/src/useUser.test.js @@ -5,6 +5,7 @@ import { useUser } from './hooks' import store from './store' import init from './__mocks__/@skolplattformen/embedded-api' import createStorage from './__mocks__/AsyncStorage' +import reporter from './__mocks__/reporter' const pause = (ms = 0) => new Promise((r) => setTimeout(r, ms)) @@ -13,7 +14,13 @@ describe('useUser()', () => { let storage let response const wrapper = ({ children }) => ( - {children} + + {children} + ) beforeEach(() => { response = { id: 1 } @@ -130,4 +137,76 @@ describe('useUser()', () => { expect(storage.cache.user).toEqual('{"id":2}') }) }) + it('retries if api fails', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getUser.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useUser(), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('loading') + expect(result.current.data).toEqual({ id: 2 }) + + jest.advanceTimersToNextTimer() + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.status).toEqual('loaded') + expect(result.current.data).toEqual({ id: 1 }) + }) + }) + it('gives up after 3 retries', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getUser.mockRejectedValueOnce(error) + api.getUser.mockRejectedValueOnce(error) + api.getUser.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useUser(), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('loading') + expect(result.current.data).toEqual({ id: 2 }) + + jest.advanceTimersToNextTimer() + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + expect(result.current.status).toEqual('error') + expect(result.current.data).toEqual({ id: 2 }) + }) + }) + it('reports if api fails', async () => { + await act(async () => { + api.isLoggedIn = true + const error = new Error('fail') + api.getUser.mockRejectedValueOnce(error) + + const { result, waitForNextUpdate } = renderHook(() => useUser(), { wrapper }) + + await waitForNextUpdate() + await waitForNextUpdate() + await waitForNextUpdate() + + expect(result.current.error).toEqual(error) + + expect(reporter.error).toHaveBeenCalledWith(error, 'Error getting USER from API') + }) + }) })