-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor: extract common logic from PushDispatcher to hooks, add inte…
…gration test
- Loading branch information
1 parent
f83d6e1
commit 11f8928
Showing
5 changed files
with
334 additions
and
124 deletions.
There are no files selected for viewing
181 changes: 181 additions & 0 deletions
181
dev-client/__tests__/integration/components/sync/PushDispatcher.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,181 @@ | ||
/* | ||
* Copyright © 2024 Technology Matters | ||
* | ||
* This program is free software: you can redistribute it and/or modify | ||
* it under the terms of the GNU Affero General Public License as published | ||
* by the Free Software Foundation, either version 3 of the License, or | ||
* (at your option) any later version. | ||
* | ||
* This program is distributed in the hope that it will be useful, | ||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
* GNU Affero General Public License for more details. | ||
* | ||
* You should have received a copy of the GNU Affero General Public License | ||
* along with this program. If not, see https://www.gnu.org/licenses/. | ||
*/ | ||
|
||
import {waitFor} from '@testing-library/react-native'; | ||
import {render} from '@testing/integration/utils'; | ||
|
||
import * as syncHooks from 'terraso-mobile-client/components/sync/hooks/syncHooks'; | ||
import { | ||
PUSH_DEBOUNCE_MS, | ||
PUSH_RETRY_INTERVAL_MS, | ||
PushDispatcher, | ||
} from 'terraso-mobile-client/components/sync/PushDispatcher'; | ||
|
||
jest.mock('terraso-mobile-client/components/sync/hooks/syncHooks', () => { | ||
return { | ||
useDebouncedIsOffline: jest.fn(), | ||
useDebouncedUnsyncedSiteIds: jest.fn(), | ||
useIsLoggedIn: jest.fn(), | ||
usePushDispatch: jest.fn(), | ||
useRetryInterval: jest.fn(), | ||
}; | ||
}); | ||
|
||
describe('PushDispatcher', () => { | ||
let useDebouncedIsOffline = jest.mocked(syncHooks.useDebouncedIsOffline); | ||
let useIsLoggedIn = jest.mocked(syncHooks.useIsLoggedIn); | ||
let useDebouncedUnsyncedSiteIds = jest.mocked( | ||
syncHooks.useDebouncedUnsyncedSiteIds, | ||
); | ||
|
||
let dispatchPush = jest.fn(); | ||
let usePushDispatch = jest.mocked(syncHooks.usePushDispatch); | ||
|
||
let beginRetry = jest.fn(); | ||
let endRetry = jest.fn(); | ||
let useRetryInterval = jest.mocked(syncHooks.useRetryInterval); | ||
|
||
beforeEach(() => { | ||
useDebouncedIsOffline.mockReset(); | ||
useIsLoggedIn.mockReset(); | ||
useDebouncedUnsyncedSiteIds.mockReset(); | ||
useDebouncedUnsyncedSiteIds.mockReset(); | ||
useDebouncedUnsyncedSiteIds.mockReset(); | ||
|
||
dispatchPush.mockReset(); | ||
usePushDispatch.mockReset(); | ||
usePushDispatch.mockReturnValue(dispatchPush); | ||
|
||
beginRetry.mockReset(); | ||
endRetry.mockReset(); | ||
useRetryInterval.mockReset(); | ||
jest.mocked(useRetryInterval).mockReturnValue({ | ||
beginRetry: beginRetry, | ||
endRetry: endRetry, | ||
}); | ||
}); | ||
|
||
test('uses correct interval for debounces', async () => { | ||
render(<PushDispatcher />); | ||
|
||
expect(useDebouncedIsOffline).toHaveBeenCalledWith(PUSH_DEBOUNCE_MS); | ||
expect(useDebouncedUnsyncedSiteIds).toHaveBeenCalledWith(PUSH_DEBOUNCE_MS); | ||
}); | ||
|
||
test('uses correct interval for retry', async () => { | ||
render(<PushDispatcher />); | ||
|
||
expect(useRetryInterval).toHaveBeenCalledWith( | ||
PUSH_RETRY_INTERVAL_MS, | ||
dispatchPush, | ||
); | ||
}); | ||
|
||
test('uses correct site IDs for push dispatch', async () => { | ||
useDebouncedUnsyncedSiteIds.mockReturnValue(['abcd']); | ||
|
||
render(<PushDispatcher />); | ||
|
||
expect(usePushDispatch).toHaveBeenCalledWith(['abcd']); | ||
}); | ||
|
||
test('does not dispatch or retry by default', async () => { | ||
useIsLoggedIn.mockReturnValue(false); | ||
useDebouncedIsOffline.mockReturnValue(true); | ||
useDebouncedUnsyncedSiteIds.mockReturnValue([]); | ||
|
||
render(<PushDispatcher />); | ||
|
||
expect(dispatchPush).toHaveBeenCalledTimes(0); | ||
expect(beginRetry).toHaveBeenCalledTimes(0); | ||
expect(endRetry).toHaveBeenCalledTimes(0); | ||
}); | ||
|
||
test('dispatches an initial push when conditions are met', async () => { | ||
useIsLoggedIn.mockReturnValue(true); | ||
useDebouncedIsOffline.mockReturnValue(false); | ||
useDebouncedUnsyncedSiteIds.mockReturnValue(['abcd']); | ||
|
||
dispatchPush.mockResolvedValue({payload: {}}); | ||
render(<PushDispatcher />); | ||
|
||
expect(dispatchPush).toHaveBeenCalledTimes(1); | ||
expect(beginRetry).toHaveBeenCalledTimes(0); | ||
expect(endRetry).toHaveBeenCalledTimes(0); | ||
}); | ||
|
||
test('begins retry when push has error', async () => { | ||
useIsLoggedIn.mockReturnValue(true); | ||
useDebouncedIsOffline.mockReturnValue(false); | ||
useDebouncedUnsyncedSiteIds.mockReturnValue(['abcd']); | ||
|
||
dispatchPush.mockResolvedValue({payload: {error: 'error'}}); | ||
render(<PushDispatcher />); | ||
|
||
await waitFor(() => expect(beginRetry).toHaveBeenCalledTimes(1)); | ||
}); | ||
|
||
test('begins retry when push is rejected', async () => { | ||
useIsLoggedIn.mockReturnValue(true); | ||
useDebouncedIsOffline.mockReturnValue(false); | ||
useDebouncedUnsyncedSiteIds.mockReturnValue(['abcd']); | ||
|
||
dispatchPush.mockRejectedValue('error'); | ||
render(<PushDispatcher />); | ||
|
||
await waitFor(() => expect(beginRetry).toHaveBeenCalledTimes(1)); | ||
}); | ||
|
||
test('ends retry when logged-in changes', async () => { | ||
useIsLoggedIn.mockReturnValue(true); | ||
useDebouncedIsOffline.mockReturnValue(false); | ||
useDebouncedUnsyncedSiteIds.mockReturnValue(['abcd']); | ||
dispatchPush.mockRejectedValue('error'); | ||
const handle = render(<PushDispatcher />); | ||
|
||
useIsLoggedIn.mockReturnValue(false); | ||
handle.rerender(<PushDispatcher />); | ||
|
||
await waitFor(() => expect(endRetry).toHaveBeenCalledTimes(1)); | ||
}); | ||
|
||
test('ends retry when online changes', async () => { | ||
useIsLoggedIn.mockReturnValue(true); | ||
useDebouncedIsOffline.mockReturnValue(false); | ||
useDebouncedUnsyncedSiteIds.mockReturnValue(['abcd']); | ||
dispatchPush.mockRejectedValue('error'); | ||
const handle = render(<PushDispatcher />); | ||
|
||
useDebouncedIsOffline.mockReturnValue(true); | ||
handle.rerender(<PushDispatcher />); | ||
|
||
await waitFor(() => expect(endRetry).toHaveBeenCalledTimes(1)); | ||
}); | ||
|
||
test('ends retry when unsynced ids changes', async () => { | ||
useIsLoggedIn.mockReturnValue(true); | ||
useDebouncedIsOffline.mockReturnValue(false); | ||
useDebouncedUnsyncedSiteIds.mockReturnValue(['abcd']); | ||
dispatchPush.mockRejectedValue('error'); | ||
const handle = render(<PushDispatcher />); | ||
|
||
useDebouncedUnsyncedSiteIds.mockReturnValue([]); | ||
handle.rerender(<PushDispatcher />); | ||
|
||
await waitFor(() => expect(endRetry).toHaveBeenCalledTimes(1)); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
/* | ||
* Copyright © 2024 Technology Matters | ||
* | ||
* This program is free software: you can redistribute it and/or modify | ||
* it under the terms of the GNU Affero General Public License as published | ||
* by the Free Software Foundation, either version 3 of the License, or | ||
* (at your option) any later version. | ||
* | ||
* This program is distributed in the hope that it will be useful, | ||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
* GNU Affero General Public License for more details. | ||
* | ||
* You should have received a copy of the GNU Affero General Public License | ||
* along with this program. If not, see https://www.gnu.org/licenses/. | ||
*/ | ||
|
||
import {useEffect} from 'react'; | ||
|
||
import { | ||
useDebouncedIsOffline, | ||
useDebouncedUnsyncedSiteIds, | ||
useIsLoggedIn, | ||
usePushDispatch, | ||
useRetryInterval, | ||
} from 'terraso-mobile-client/components/sync/hooks/syncHooks'; | ||
|
||
export const PUSH_DEBOUNCE_MS = 500; | ||
export const PUSH_RETRY_INTERVAL_MS = 1000 * 60; | ||
|
||
/** | ||
* Automated system to dispatch push operations to the server. | ||
* | ||
* Listens for unsynced data and, when the app is online, sets up a Redux dispatch to push | ||
* the relevant entities to the server. If the push does not succeed, sets an interval to | ||
* retry the push until it succeeds. Retries are canceled any time the underlying state changes | ||
* (i.e., the set of unsynced data changes due to a success or new user-made changes.) | ||
*/ | ||
export const PushDispatcher = () => { | ||
/* Determined whether the user is logged in before doing anything. */ | ||
const isLoggedIn = useIsLoggedIn(); | ||
|
||
/* Sebounce offline state so we know when it's safe to attempt a push. */ | ||
const isOffline = useDebouncedIsOffline(PUSH_DEBOUNCE_MS); | ||
|
||
/* Also debounce unsynced IDs so we have a stable state when queuing up a push */ | ||
const unsyncedSiteIds = useDebouncedUnsyncedSiteIds(PUSH_DEBOUNCE_MS); | ||
|
||
/* Set up a callback for the dispatcher to use when it determines a push is needed. */ | ||
const dispatchPush = usePushDispatch(unsyncedSiteIds); | ||
|
||
/*A push is needed when the user is logged in, not offline, and has unsynced data. */ | ||
const needsPush = isLoggedIn && !isOffline && unsyncedSiteIds.length > 0; | ||
|
||
/* Set up retry mechanism which will dispatch the push action when it begins. */ | ||
const {beginRetry, endRetry} = useRetryInterval( | ||
PUSH_RETRY_INTERVAL_MS, | ||
dispatchPush, | ||
); | ||
|
||
useEffect(() => { | ||
/* Dispatch a push if needed */ | ||
if (needsPush) { | ||
dispatchPush() | ||
/* If the initial push failed, begin a retry cycle */ | ||
.then(result => { | ||
if (!result.payload || 'error' in result.payload) { | ||
beginRetry(); | ||
} | ||
}) | ||
.catch(beginRetry); | ||
} | ||
|
||
/* Cancel any pending retries when push input changes or component unmounts */ | ||
return endRetry; | ||
}, [needsPush, dispatchPush, beginRetry, endRetry]); | ||
|
||
return <></>; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
/* | ||
* Copyright © 2024 Technology Matters | ||
* | ||
* This program is free software: you can redistribute it and/or modify | ||
* it under the terms of the GNU Affero General Public License as published | ||
* by the Free Software Foundation, either version 3 of the License, or | ||
* (at your option) any later version. | ||
* | ||
* This program is distributed in the hope that it will be useful, | ||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
* GNU Affero General Public License for more details. | ||
* | ||
* You should have received a copy of the GNU Affero General Public License | ||
* along with this program. If not, see https://www.gnu.org/licenses/. | ||
*/ | ||
|
||
import {useCallback, useRef} from 'react'; | ||
|
||
import {useDebounce} from 'use-debounce'; | ||
|
||
import {useIsOffline} from 'terraso-mobile-client/hooks/connectivityHooks'; | ||
import {selectUnsyncedSiteIds} from 'terraso-mobile-client/model/soilId/soilIdSelectors'; | ||
import {pushSoilData} from 'terraso-mobile-client/model/soilId/soilIdSlice'; | ||
import {useDispatch, useSelector} from 'terraso-mobile-client/store'; | ||
|
||
export const useIsLoggedIn = () => { | ||
return useSelector(state => !!state.account.currentUser.data); | ||
}; | ||
|
||
export const useDebouncedIsOffline = (interval: number) => { | ||
const [isOffline] = useDebounce(useIsOffline(), interval); | ||
return isOffline; | ||
}; | ||
|
||
export const useDebouncedUnsyncedSiteIds = (interval: number) => { | ||
const [unsyncedSiteIds] = useDebounce( | ||
useSelector(selectUnsyncedSiteIds), | ||
interval, | ||
); | ||
return unsyncedSiteIds; | ||
}; | ||
|
||
export const usePushDispatch = (siteIds: string[]) => { | ||
const dispatch = useDispatch(); | ||
return useCallback(() => { | ||
return dispatch(pushSoilData(siteIds)); | ||
}, [dispatch, siteIds]); | ||
}; | ||
|
||
export const useRetryInterval = (interval: number, action: () => void) => { | ||
/* | ||
* Note that we are using a React ref to keep a stable input value for other hooks. | ||
* (If we just used a state, we'd have extra re-renders when clearing or initializing | ||
* a retry, which would complicate the logic needed to cancel retries.) | ||
*/ | ||
const handle = useRef(undefined as number | undefined); | ||
|
||
const endRetry = useCallback(() => { | ||
if (handle.current !== undefined) { | ||
clearInterval(handle.current); | ||
handle.current = undefined; | ||
} | ||
}, [handle]); | ||
|
||
const beginRetry = useCallback(() => { | ||
/* Clear any ongoing retry cycles before beginning a new one */ | ||
endRetry(); | ||
handle.current = setInterval(action, interval); | ||
}, [endRetry, handle, action, interval]); | ||
|
||
return {beginRetry, endRetry}; | ||
}; |
Oops, something went wrong.