Skip to content

Commit

Permalink
refactor: extract common logic from PushDispatcher to hooks, add inte…
Browse files Browse the repository at this point in the history
…gration test
  • Loading branch information
tm-ruxandra committed Nov 11, 2024
1 parent f83d6e1 commit 11f8928
Show file tree
Hide file tree
Showing 5 changed files with 334 additions and 124 deletions.
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));
});
});
2 changes: 1 addition & 1 deletion dev-client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {captureConsoleIntegration} from '@sentry/core';
import * as Sentry from '@sentry/react-native';

import {RestrictByFlag} from 'terraso-mobile-client/components/RestrictByFlag';
import {PushDispatcher} from 'terraso-mobile-client/components/sync/PushDispatcher';
import {APP_CONFIG} from 'terraso-mobile-client/config';
import {ForegroundPermissionsProvider} from 'terraso-mobile-client/context/AppPermissionsContext';
import {ConnectivityContextProvider} from 'terraso-mobile-client/context/connectivity/ConnectivityContext';
Expand All @@ -54,7 +55,6 @@ import {SitesScreenContextProvider} from 'terraso-mobile-client/context/SitesScr
import {RootNavigator} from 'terraso-mobile-client/navigation/navigators/RootNavigator';
import {Toasts} from 'terraso-mobile-client/screens/Toasts';
import {createStore} from 'terraso-mobile-client/store';
import {PushDispatcher} from 'terraso-mobile-client/store/components/PushDispatcher';
import {
loadPersistedReduxState,
patchPersistedReduxState,
Expand Down
79 changes: 79 additions & 0 deletions dev-client/src/components/sync/PushDispatcher.tsx
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 <></>;
};
73 changes: 73 additions & 0 deletions dev-client/src/components/sync/hooks/syncHooks.tsx
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};
};
Loading

0 comments on commit 11f8928

Please sign in to comment.