diff --git a/src/utils/beacon/geolocation.ts b/src/utils/beacon/geolocation.ts index 3c0d13251e..b21fddad63 100644 --- a/src/utils/beacon/geolocation.ts +++ b/src/utils/beacon/geolocation.ts @@ -33,7 +33,7 @@ export enum GeolocationError { const GeolocationOptions = { timeout: 5000, - maximumAge: 1000, + maximumAge: 2000, }; const isGeolocationPositionError = (error: unknown): error is GeolocationPositionError => @@ -112,13 +112,31 @@ export const mapGeolocationPositionToTimedGeo = (position: GeolocationPosition): return { timestamp: position.timestamp, geoUri: getGeoUri(genericPositionFromGeolocation(position)) }; }; +/** + * Gets current position, returns a promise + * @returns Promise + */ +export const getCurrentPosition = async (): Promise => { + try { + const position = await new Promise((resolve: PositionCallback, reject) => { + getGeolocation().getCurrentPosition(resolve, reject, GeolocationOptions); + }); + return position; + } catch (error) { + throw new Error(mapGeolocationError(error)); + } +}; + +export type ClearWatchCallback = () => void; export const watchPosition = ( onWatchPosition: PositionCallback, - onWatchPositionError: (error: GeolocationError) => void): () => void => { + onWatchPositionError: (error: GeolocationError) => void): ClearWatchCallback => { try { const onError = (error) => onWatchPositionError(mapGeolocationError(error)); const watchId = getGeolocation().watchPosition(onWatchPosition, onError, GeolocationOptions); - const clearWatch = () => getGeolocation().clearWatch(watchId); + const clearWatch = () => { + getGeolocation().clearWatch(watchId); + }; return clearWatch; } catch (error) { throw new Error(mapGeolocationError(error)); diff --git a/test/test-utils/beacon.ts b/test/test-utils/beacon.ts index fa6bcebe11..09326a1434 100644 --- a/test/test-utils/beacon.ts +++ b/test/test-utils/beacon.ts @@ -141,3 +141,27 @@ export const mockGeolocation = (): MockedObject => { return mockGeolocation; }; + +/** + * Creates a mock watchPosition implementation + * that calls success callback at the provided delays + * ``` + * geolocation.watchPosition.mockImplementation([0, 1000, 5000, 50]) + * ``` + * will call the provided handler with a mock position at + * next tick, 1000ms, 6000ms, 6050ms + */ +export const watchPositionMockImplementation = (delays: number[]) => { + return (callback: PositionCallback) => { + const position = makeGeolocationPosition({}); + + let totalDelay = 0; + delays.map(delayMs => { + totalDelay += delayMs; + const timeout = setTimeout(() => { + callback({ ...position, timestamp: position.timestamp + totalDelay }); + }, totalDelay); + return timeout; + }); + }; +}; diff --git a/test/test-utils/utilities.ts b/test/test-utils/utilities.ts index f826fe1679..a4370fbc9e 100644 --- a/test/test-utils/utilities.ts +++ b/test/test-utils/utilities.ts @@ -31,6 +31,16 @@ export const findByTagAndTestId = findByTagAndAttr('data-test-id'); export const flushPromises = async () => await new Promise(resolve => setTimeout(resolve)); +// with jest's modern fake timers process.nextTick is also mocked, +// flushing promises in the normal way then waits for some advancement +// of the fake timers +// https://gist.github.com/apieceofbart/e6dea8d884d29cf88cdb54ef14ddbcc4?permalink_comment_id=4018174#gistcomment-4018174 +export const flushPromisesWithFakeTimers = async (): Promise => { + const promise = new Promise(resolve => process.nextTick(resolve)); + jest.advanceTimersByTime(1); + await promise; +}; + /** * Call fn before calling componentDidUpdate on a react component instance, inst. * @param {React.Component} inst an instance of a React component. @@ -57,3 +67,13 @@ export function waitForUpdate(inst: React.Component, updates = 1): Promise }; }); } + +/** + * Advance jests fake timers and Date.now mock by ms + * Useful for testing code using timeouts or intervals + * that also checks timestamps + */ +export const advanceDateAndTime = (ms: number) => { + jest.spyOn(global.Date, 'now').mockReturnValue(Date.now() + ms); + jest.advanceTimersByTime(ms); +}; diff --git a/test/utils/beacon/geolocation-test.ts b/test/utils/beacon/geolocation-test.ts index 51aabd3600..0b2ad4930f 100644 --- a/test/utils/beacon/geolocation-test.ts +++ b/test/utils/beacon/geolocation-test.ts @@ -23,6 +23,7 @@ import { mapGeolocationPositionToTimedGeo, watchPosition, } from "../../../src/utils/beacon"; +import { getCurrentPosition } from "../../../src/utils/beacon/geolocation"; import { makeGeolocationPosition, mockGeolocation } from "../../test-utils/beacon"; describe('geolocation utilities', () => { @@ -166,7 +167,7 @@ describe('geolocation utilities', () => { const [, , options] = geolocation.watchPosition.mock.calls[0]; expect(options).toEqual({ - maximumAge: 1000, + maximumAge: 2000, timeout: 5000, }); }); @@ -204,4 +205,36 @@ describe('geolocation utilities', () => { expect(errorHandler).toHaveBeenCalledWith(GeolocationError.PermissionDenied); }); }); + + describe('getCurrentPosition()', () => { + it('throws with unavailable error when geolocation is not available', async () => { + // suppress expected errors from test log + jest.spyOn(logger, 'error').mockImplementation(() => { }); + + // remove the mock we added + // @ts-ignore illegal assignment to readonly property + navigator.geolocation = undefined; + + await expect(() => getCurrentPosition()).rejects.toThrow(GeolocationError.Unavailable); + }); + + it('throws with geolocation error when geolocation.getCurrentPosition fails', async () => { + // suppress expected errors from test log + jest.spyOn(logger, 'error').mockImplementation(() => { }); + + const timeoutError = getMockGeolocationPositionError(3, 'message'); + geolocation.getCurrentPosition.mockImplementation((callback, error) => error(timeoutError)); + + await expect(() => getCurrentPosition()).rejects.toThrow(GeolocationError.Timeout); + }); + + it('resolves with current location', async () => { + jest.spyOn(logger, 'error').mockImplementation(() => { }); + + geolocation.getCurrentPosition.mockImplementation((callback, error) => callback(defaultPosition)); + + const result = await getCurrentPosition(); + expect(result).toEqual(defaultPosition); + }); + }); });