I'm trying to test the following scenario:
A user with an expired token tries to access a resource he is not authorized
The resources returns a 401 error
The application updates a global state "isExpiredSession" to true
For this, I have 2 providers:
The authentication provider, with the global authentication state
The one responsible to fetch the resource
There are custom hooks for both, exposing shared logic of these components, i.e: fetchResource/expireSesssion
When the resource fetched returns a 401 status, it sets the isExpiredSession value in the authentication provider, through the sharing of a setState method.
AuthenticationContext.js
import React, { createContext, useState } from 'react';
const AuthenticationContext = createContext([{}, () => {}]);
const initialState = {
userInfo: null,
errorMessage: null,
isExpiredSession: false,
};
const AuthenticationProvider = ({ authStateTest, children }) => {
const [authState, setAuthState] = useState(initialState);
return (
<AuthenticationContext.Provider value={[authStateTest || authState, setAuthState]}>
{ children }
</AuthenticationContext.Provider>);
};
export { AuthenticationContext, AuthenticationProvider, initialState };
useAuthentication.js
import { AuthenticationContext, initialState } from './AuthenticationContext';
const useAuthentication = () => {
const [authState, setAuthState] = useContext(AuthenticationContext);
...
const expireSession = () => {
setAuthState({
...authState,
isExpiredSession: true,
});
};
...
return { expireSession };
}
ResourceContext.js is similar to the authentication, exposing a Provider
And the useResource.js has something like this:
const useResource = () => {
const [resourceState, setResourceState] = useContext(ResourceContext);
const [authState, setAuthState] = useContext(AuthenticationContext);
const { expireSession } = useAuthentication();
const getResource = () => {
const { values } = resourceState;
const { userInfo } = authState;
return MyService.fetchResource(userInfo.token)
.then((result) => {
if (result.ok) {
result.json()
.then((json) => {
setResourceState({
...resourceState,
values: json,
});
})
.catch((error) => {
setErrorMessage(`Error decoding response: ${error.message}`);
});
} else {
const errorMessage = result.status === 401 ?
'Your session is expired, please login again' :
'Error retrieving earnings';
setErrorMessage(errorMessage);
expireSession();
}
})
.catch((error) => {
setErrorMessage(error.message);
});
};
...
Then, on my tests, using react-hooks-testing-library I do the following:
it.only('Should fail to get resource with invalid session', async () => {
const wrapper = ({ children }) => (
<AuthenticationProvider authStateTest={{ userInfo: { token: 'FOOBAR' }, isExpiredSession: false }}>
<ResourceProvider>{children}</ResourceProvider>
</AuthenticationProvider>
);
const { result, waitForNextUpdate } = renderHook(() => useResource(), { wrapper });
fetch.mockResponse(JSON.stringify({}), { status: 401 });
act(() => result.current.getResource());
await waitForNextUpdate();
expect(result.current.errorMessage).toEqual('Your session is expired, please login again');
// Here is the issue, how to test the global value of the Authentication context? the line below, of course, doesn't work
expect(result.current.isExpiredSession).toBeTruthy();
});
I have tried a few solutions:
Rendering the useAuthentication on the tests as well, however, the changes made by the Resource doesn't seem to reflect on it.
Exposing the isExpiredSession variable through the Resource hook, i.e:
return {
...
isExpiredSession: authState.isExpiredSession,
...
};
I was expecting that by then this line would work:
expect(result.current.isExpiredSession).toBeTruthy();
But still not working and the value is still false
Any idea how can I implement a solution for this problem?
Author of react-hooks-testing-library here.
It's a bit hard without being able to run the code, but I think your issue might be the multiple state updates not batching correctly as they are not wrapped in an act call. The ability to act on async calls is in an alpha release of react (v16.9.0-alpha.0) and we have an issue tracking it as well.
So there may be 2 ways to solve it:
Update to the alpha version and a move the waitForNextUpdate into the act callback
npm install react#16.9.0-alpha.0
it.only('Should fail to get resource with invalid session', async () => {
const wrapper = ({ children }) => (
<AuthenticationProvider authStateTest={{ userInfo: { token: 'FOOBAR' }, isExpiredSession: false }}>
<ResourceProvider>{children}</ResourceProvider>
</AuthenticationProvider>
);
const { result, waitForNextUpdate } = renderHook(() => useResource(), { wrapper });
fetch.mockResponse(JSON.stringify({}), { status: 401 });
await act(async () => {
result.current.getResource();
await waitForNextUpdate();
});
expect(result.current.errorMessage).toEqual('Your session is expired, please login again');
expect(result.current.isExpiredSession).toBeTruthy();
});
Add in a second waitForNextUpdate call
it.only('Should fail to get resource with invalid session', async () => {
const wrapper = ({ children }) => (
<AuthenticationProvider authStateTest={{ userInfo: { token: 'FOOBAR' }, isExpiredSession: false }}>
<ResourceProvider>{children}</ResourceProvider>
</AuthenticationProvider>
);
const { result, waitForNextUpdate } = renderHook(() => useResource(), { wrapper });
fetch.mockResponse(JSON.stringify({}), { status: 401 });
act(() => result.current.getResource());
// await setErrorMessage to happen
await waitForNextUpdate();
// await setAuthState to happen
await waitForNextUpdate();
expect(result.current.errorMessage).toEqual('Your session is expired, please login again');
expect(result.current.isExpiredSession).toBeTruthy();
});
Your appetite for using alpha versions will likely dictate which option you go for, but, option 1 is the more "future proof". Option 2 may stop working one day once the alpha version hits a stable release.
Related
I have a toast package that receives a standard promise as an argument and does something upon success or error:
toast.promise(
updateNotePromise,
{
loading: 'Saving...',
success: (data: any) => 'Note saved!',
error: (err) => err.toString()
}
);
This is the promise I pass to the toast, but it returns a <PayloadAction> because it calls a thunk:
const updateNotePromise = await dispatch(
updateNoteInFirestore({ note: noteInput, noteDocId: noteProp.docId })
);
How can I return success or error from this dispatch thunk operation?
I thought of processing the returned <PayloadAction> by wrapping the thunk. This would be my naive approach:
const updateNotePromise = async(): Promise<{success: boolean | error: any}> => {
try {
await dispatch(updateNoteInFirestore({ note: noteInput, noteDocId: noteProp.docId
return success }))
}
catch {
(error)=> return error}
Am I on the right track?
Edit: here's the thunk code:
export const updateNoteInFirestore = createAsyncThunk(
'updateNoteInFirestore',
async (
{ note, noteDocId }: { note: string; noteDocId?: string },
{ getState, dispatch }
) => {
const poolState = (getState() as RootState).customerPool.pool;
const userState = (getState() as RootState).user;
const time = Timestamp.now();
const path = noteDocId ? noteDocId : undefined;
const message = note;
if (poolState?.docID) {
await notesService.updateNote(
{
pool: poolState.docID,
customer: userState?.user?.uid ?? 'Undefined Customer',
//we do not update dateFirstCreated
...(path ? { dateLastUpdated: time } : { dateFirstCreated: time }),
dateLastUpdated: time,
message: message,
editHistory: [],
seenByAdmin: false
},
path
);
dispatch(fetchNotesByCustomerId(userState?.user?.uid));
return { error: false };
}
return { error: true };
}
);
If you want to return an error with createAsyncThunk you can use rejectWithValue
const fetchUserById = createAsyncThunk(
'users/fetchById',
async (userId, { rejectWithValue }) => {
const response = await fetch(`https://example.com/api/stuff`)
if (response.status === 404)
return rejectWithValue(new Error("Impossible to do stuff"));
return response.json()
}
)
I think for your use case, it's better to use a promise-based function followed by a dispatch reducer action rather than an asyncThunk.
asyncThunks return value can only be consumed by builders that are defined within slice as far as I know.
You need to break your problem into three steps:
Creating a wrapper promiseFunction as needed by your toast.
Creating a promise helper function where you must be able to supply the variables poolState and userState as these variables were accessed through getState() in your async thunk but that isn't possible in your promiseHelperFunction If you define promiseHelperFunction within your functional component you could use useAppSelector to access those states. I have added the comment for the same in the promiseHelperFunction.
Now once you're done with this you can now consume promiseFunction in your toast.
You might need to import fetchNotesByCustomerId that you're using in your asyncThunk as it may not be accessible to the component where you're writing the toast implementation.
Here's the code for same:
const updateNotePromise = async () => {
return updatePromiseHelperFunction({
note: noteInput,
noteDocId: noteProp.docId,
});
};
const updatePromiseHelperFunction = async ({
note,
noteDocId,
}: {
note: string;
noteDocId?: string;
}) => {
/*
// Before the Promise you must ensure you're able to access these variables:
const poolState = useAppSelector(state => state.customerPool.pool);
const userState = useAppSelector(state => state.user);
*/
const time = Timestamp.now();
const path = noteDocId ? noteDocId : undefined;
if (poolState?.docID) {
await notesService.updateNote(
{
pool: poolState.docID,
customer: userState?.user?.uid ?? 'Undefined Customer',
//we do not update dateFirstCreated
...(path ? { dateLastUpdated: time } : { dateFirstCreated: time }),
dateLastUpdated: time,
message: note,
editHistory: [],
seenByAdmin: false,
},
path
);
dispatch(fetchNotesByCustomerId(userState?.user?.uid));
return { error: false };
}
return { error: true };
};
I want to be able to make a dispatch only if a document in Firebase gets updated, I have two categorised error at hand, a Firebase error, and no connection error, in other words, how to know for sure that the updateDoc() has passed so I can make the dispatch to change the current state according to that update. If not, of course show an error on the UI, the issue here is I am passing the dispatch after the await function. If I were using mongo I would handle this in the response.
const handelColor = (e: React.FormEvent<HTMLInputElement>): void => {
const updateData = async () => {
const docRefCol = doc(db, 'collection', currentUser.uid);
throw 'simulated no premission error';
await updateDoc(docRefCol, { customColor: 'red' });
};
throw 'simulated no connection error';
updateData().catch((error) => {
const errorCode = error.code;
alert(errorCode);
});
throw 'simulated faild updated data erorr';
alert('dispatch to local state');
//dispatch(addColor({ select: 'customColor', value: colors[e.currentTarget.value] }));
};
I did come up with the following solution but still I think I still can do better.
const updateData = async () => {
if (!window.navigator.onLine) {
throw { code: 'Please check your connection' };
}
const docRefCol = doc(db, 'collection', currentUser.uid);
await await updateDoc(docRefCol, { customColor: 'red' });
dispatch(addColor({ select: 'customColor', value: colors[e.currentTarget.value] }));
};
updateData().catch((error) => {
const errorCode = error.code;
alert(errorCode);
});
You can try to follow this code (untested) to avoid redundancy in calling your updateData:
const updateData = async () => {
if (!window.navigator.onLine) {
throw { code: 'Please check your connection' };
}
const docRefCol = doc(db, 'collection', currentUser.uid);
await updateDoc(docRefCol, { customColor: 'red' })
.then(() => {
dispatch(addColor({ select: 'customColor', value: colors[e.currentTarget.value] }));
})
.catch((error) => {
alert(error);
});
};
For more reference, you can check this related StackOverflow post that also discusses what you're trying to achieve.
I'm defining a Redux service to call an api endpoint:
export const TrackersApi = {
getBasicsTrackers: async (): Promise<ReturnType<typeof recreator>> => {
const url = "/api/getbasictrackers"
const {data, statusText} = await axios.get(url, { withCredentials: true });
if(statusText !== 'OK' && statusText !== 'No Content') throw new Error('Wrong response from getbasictrackers')
const result = recreator(data)
console.log({result})
return result
},
The log returns the json response.
Then I inject this in a component on mount:
componentDidMount = () => {
store.dispatch(getBasicTrackers()).then(() => {
if(this.props.trackers) {
this.setState({
sortedAndFilteredTrackers : this.props.trackers
})
}
if(this.props.folders) {
this.setState({
sortedAndFilteredFolders: this.props.folders
})
}
console.log('trackers', this.props.trackers)
})
}
However the log here returns an empty array. I tried first without the then and I had the same issue.
How can I make it so that the setState is called only once the API response is received?
Additional info: This props is then used to fill in a table. However the table remains empty, which is the key issue here
It is mapped through this:
const mapStateToProps = (state: ReduxStore.State) => ({
trackers: state.trackersData.rawTrackers ? Object.values(state.trackersData.rawTrackers).map(item => ({...item, checked: false})) : [],
folders: state.trackersData?.folders ? Object.values(state.trackersData.folders).map((folder: any) => ({...folder.summary, checked: false})) : []
})
I am trying to write unit test cases for my models using react testing library but i am facing some issues executing the test cases.
My effects.js
export async function getStoredData(param1, store, param2) {
try {
dispatch(setLoading(true, 'getStoredData'));
// Check if key exists in store
const inputKeyCode = getInputKeyCode([param1, param2]);
let response = getUserDataState(store)[inputKeyCode];
if (!response) {
response = await getUserApi(param1, param2);
this.setUserData({ keyCode: inputKeyCode, keyValue: response });
}
return response;
} catch (error) {
// dispatch error
} finally {
dispatch(setLoading(false, 'getStoredData'));
}
}
My reducers.js
const INITIAL_STATE = {
userData: {},
};
const setUserData = (state, { key, value }) => ({ // {key: value}
...state,
userData: {
...state.userData,
[key]: value,
},
});
effects.test.js
import { getUserApi } from '../../../api/common';
jest.mock('../../../store', () => ({ dispatch: jest.fn() }));
jest.mock('../../../api/common', () => ({ getUserApi: jest.fn() }));
describe('getStoredData', () => {
const responseData = {};
setWith(responseData, 'data.userInformation', 12345);
const setUserData = jest.fn();
test('success', async () => {
getUserApi.mockResolvedValue(responseData);
await testModel.effects().getStoredData.call({ setuserData });
expect(setuserData).toHaveBeenCalled();
expect(setuserData).toHaveBeenCalledWith(12345);
});
test('failure', async () => {
getUserApi.mockRejectedValue(errorMsg);
await testModel.effects().getStoredData.call({ setuserData });
expect(showNotification).toHaveBeenCalled();
expect(showNotification).toHaveBeenCalledWith('error');
});
});
This gives me below error-
Expected mock function to have been called, but it was not called.
At line- expect(setuserData).toHaveBeenCalled();
Can someone help me understand what i am doing wrong? I guess i am doing some mistake in calling the setuserData. Any help is much appreciated.
I am trying to test a Redux Observable epic which dispatches an action to invoke an other epic. Somehow the invoked epic is not called.
Lets say my epics looks like this;
const getJwtEpic = (action$, store, { api }) =>
action$.ofType('GET_JWT_REQUEST')
.switchMap(() => api.getJWT()
.map(response => {
if (response.errorCode > 0) {
return {
type: 'GET_JWT_FAILURE',
error: { code: response.errorCode, message: response.errorMessage },
};
}
return {
type: 'GET_JWT_SUCCESS',
idToken: response.id_token,
};
})
);
const bootstrapEpic = (action$, store, { api }) =>
action$.ofType('BOOTSTRAP')
.switchMap(() =>
action$.filter(({ type }) => ['GET_JWT_SUCCESS', 'GET_JWT_FAILURE'].indexOf(type) !== -1)
.take(1)
.mergeMap(action => {
if (action.type === 'GET_JWT_FAILURE') {
return Observable.of({ type: 'BOOTSTRAP_FAILURE' });
}
return api.getProfileInfo()
.map(({ profile }) => ({
type: 'BOOTSTRAP_SUCCESS', profile,
}));
})
.startWith({ type: 'GET_JWT_REQUEST' })
);
When I try to test the bootstrapEpic in Jest with the following code;
const response = {};
const api = { getJWT: jest.fn() };
api.getJWT.mockReturnValue(Promise.resolve(response));
const action$ = ActionsObservable.of(actions.bootstrap());
const epic$ = epics.bootstrapEpic(action$, null, { api });
const result = await epic$.toArray().toPromise();
console.log(result);
The console.log call gives me the following output;
[ { type: 'GET_JWT_REQUEST' } ]
Somehow the getJwtEpic isn't called at all. I guess it has something to do with the action$ observable not dispatching the GET_JWT_REQUEST action but I can't figure out why. All help is so welcome!
Assuming actions.rehydrate() returns an action of type BOOTSTRAP and the gigya stuff is a typo,
getJwtEpic isn't called because you didn't call it yourself 🤡 When you test epics by manually calling them, then it's just a function which returns an Observable, without any knowledge of the middleware or anything else. The plumbing that connects getJwtEpic as part of the root epic, and provides it with (action$, store) is part of the middleware, which you're not using in your test.
This is the right approach, testing them in isolation, without redux/middleware. 👍 So you test each epic individually, by providing it actions and dependencies, then asserting on the actions it emits and the dependencies it calls.
You'll test the success path something like this:
const api = {
getProfileInfo: () => Observable.of({ profile: 'mock profile' })
};
const action$ = ActionsObservable.of(
actions.rehydrate(),
{ type: 'GET_JWT_SUCCESS', idToken: 'mock token' }
);
const epic$ = epics.bootstrapEpic(action$, null, { api });
const result = await epic$.toArray().toPromise();
expect(result).toEqual([
{ type: 'GET_JWT_REQUEST' },
{ type: 'BOOTSTRAP_SUCCESS', profile: 'mock profile' }
]);
Then you'll test the failure path in another test by doing the same thing except giving it GET_JWT_FAILURE instead of GET_JWT_SUCCESS. You can then test getJwtEpic separately as well.
btw, ofType accepts any number of types, so you can just do action$.ofType('GET_JWT_SUCCESS', 'GET_JWT_FAILURE')