chore: test redux components with jest

This commit is contained in:
Sebastian Velez
2023-01-13 18:36:39 -05:00
parent 6dfa40e026
commit 5486a69305
12 changed files with 193 additions and 119 deletions

View File

@@ -1,12 +1,11 @@
import React from 'react'; import React from 'react';
import { render } from '@testing-library/react'; import { renderWithProviders } from '../utils/test-utils';
import MainList from './MainList'; import MainList from './MainList';
type onClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void; type onClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void;
describe('MainList', () => { describe('MainList', () => {
let disableLogging: onClick; let disableLogging: onClick;
let enableLogging: onClick; let enableLogging: onClick;
let loggedIn: boolean;
let loggingEnabled: boolean; let loggingEnabled: boolean;
let logoutUser: onClick; let logoutUser: onClick;
let totalTimeLoggedToday: string; let totalTimeLoggedToday: string;
@@ -14,17 +13,15 @@ describe('MainList', () => {
disableLogging = jest.fn(); disableLogging = jest.fn();
enableLogging = jest.fn(); enableLogging = jest.fn();
loggingEnabled = false; loggingEnabled = false;
loggedIn = false;
logoutUser = jest.fn(); logoutUser = jest.fn();
totalTimeLoggedToday = '1/1/1999'; totalTimeLoggedToday = '1/1/1999';
}); });
it('should render properly', () => { it('should render properly', () => {
const { container } = render( const { container } = renderWithProviders(
<MainList <MainList
disableLogging={disableLogging} disableLogging={disableLogging}
enableLogging={enableLogging} enableLogging={enableLogging}
loggingEnabled={loggingEnabled} loggingEnabled={loggingEnabled}
loggedIn={loggedIn}
logoutUser={logoutUser} logoutUser={logoutUser}
totalTimeLoggedToday={totalTimeLoggedToday} totalTimeLoggedToday={totalTimeLoggedToday}
/>, />,

View File

@@ -1,9 +1,11 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux';
import { ReduxSelector } from '../types/store';
import { User } from '../types/user';
export interface MainListProps { export interface MainListProps {
disableLogging: (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void; disableLogging: (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void;
enableLogging: (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void; enableLogging: (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void;
loggedIn: boolean;
loggingEnabled: boolean; loggingEnabled: boolean;
logoutUser: (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void; logoutUser: (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void;
totalTimeLoggedToday?: string; totalTimeLoggedToday?: string;
@@ -15,14 +17,17 @@ const openOptionsPage = async (): Promise<void> => {
export default function MainList({ export default function MainList({
disableLogging, disableLogging,
enableLogging, enableLogging,
loggedIn,
loggingEnabled, loggingEnabled,
logoutUser, logoutUser,
totalTimeLoggedToday, totalTimeLoggedToday,
}: MainListProps): JSX.Element { }: MainListProps): JSX.Element {
const user: User | undefined = useSelector(
(selector: ReduxSelector) => selector.currentUser.user,
);
return ( return (
<div> <div>
{loggedIn && ( {user && (
<div className="row"> <div className="row">
<div className="col-xs-12"> <div className="col-xs-12">
<blockquote> <blockquote>
@@ -34,7 +39,7 @@ export default function MainList({
</div> </div>
</div> </div>
)} )}
{loggingEnabled && loggedIn && ( {loggingEnabled && user && (
<div className="row"> <div className="row">
<div className="col-xs-12"> <div className="col-xs-12">
<p> <p>
@@ -45,7 +50,7 @@ export default function MainList({
</div> </div>
</div> </div>
)} )}
{!loggingEnabled && loggedIn && ( {!loggingEnabled && user && (
<div className="row"> <div className="row">
<div className="col-xs-12"> <div className="col-xs-12">
<p> <p>
@@ -61,7 +66,7 @@ export default function MainList({
<i className="fa fa-fw fa-cogs"></i> <i className="fa fa-fw fa-cogs"></i>
Options Options
</a> </a>
{loggedIn && ( {user && (
<div> <div>
<a href="#" className="list-group-item" onClick={logoutUser}> <a href="#" className="list-group-item" onClick={logoutUser}>
<i className="fa fa-fw fa-sign-out"></i> <i className="fa fa-fw fa-sign-out"></i>
@@ -69,7 +74,7 @@ export default function MainList({
</a> </a>
</div> </div>
)} )}
{!loggedIn && ( {!user && (
<a <a
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"

View File

@@ -1,13 +1,12 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { setValue } from '../reducers/apiKey'; import { configLogout, setApiKey } from '../reducers/configReducer';
import { userLogout } from '../reducers/currentUser';
import { ReduxSelector } from '../types/store'; import { ReduxSelector } from '../types/store';
import { User } from '../types/user'; import { User } from '../types/user';
import config from '../config/config'; import config from '../config/config';
import apiKeyInvalid from '../utils/apiKey'; import apiKeyInvalid from '../utils/apiKey';
import WakaTimeCore from '../core/WakaTimeCore'; import { fetchUserData } from '../utils/user';
import { setUser } from '../reducers/currentUser';
import changeExtensionState from '../utils/changeExtensionState';
export default function NavBar(): JSX.Element { export default function NavBar(): JSX.Element {
const [state, setState] = useState({ const [state, setState] = useState({
@@ -158,15 +157,11 @@ export default function NavBar(): JSX.Element {
if (state.apiKeyError === '' && state.apiKey !== '') { if (state.apiKeyError === '' && state.apiKey !== '') {
setState({ ...state, loading: true }); setState({ ...state, loading: true });
await browser.storage.sync.set({ apiKey: state.apiKey }); await browser.storage.sync.set({ apiKey: state.apiKey });
dispatch(setValue(state.apiKey)); dispatch(configLogout());
dispatch(userLogout());
dispatch(setApiKey(state.apiKey));
try { await fetchUserData(state.apiKey, dispatch);
const data = await WakaTimeCore.checkAuth(state.apiKey);
dispatch(setUser(data));
} catch (err: unknown) {
dispatch(setUser(undefined));
await changeExtensionState('notSignedIn');
}
setState({ ...state, loading: false }); setState({ ...state, loading: false });
} }
}} }}

View File

@@ -1,69 +1,28 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { setValue } from '../reducers/apiKey'; import { ApiKeyReducer, ReduxSelector } from '../types/store';
import { ReduxSelector } from '../types/store';
import { setUser } from '../reducers/currentUser';
import WakaTimeCore from '../core/WakaTimeCore';
import config from '../config/config'; import config from '../config/config';
import { fetchUserData } from '../utils/user';
import changeExtensionState from '../utils/changeExtensionState'; import changeExtensionState from '../utils/changeExtensionState';
import NavBar from './NavBar'; import NavBar from './NavBar';
import MainList from './MainList'; import MainList from './MainList';
const API_KEY = 'waka_3766d693-bff3-4c63-8bf5-b439f3e12301';
export default function WakaTime(): JSX.Element { export default function WakaTime(): JSX.Element {
const dispatch = useDispatch(); const dispatch = useDispatch();
const defaultState = { const defaultState = {
loggedIn: false,
loggingEnabled: config.loggingEnabled, loggingEnabled: config.loggingEnabled,
totalTimeLoggedToday: '0 minutes', totalTimeLoggedToday: '0 minutes',
}; };
const [state, setState] = useState(defaultState); const [state, setState] = useState(defaultState);
const apiKeyFromRedux: string = useSelector((selector: ReduxSelector) => selector.apiKey.value); const {
apiKey: apiKeyFromRedux,
const fetchUserData = async (): Promise<void> => { loggingEnabled,
// await browser.storage.sync.set({ apiKey: API_KEY }); totalTimeLoggedToday,
let apiKey = ''; }: ApiKeyReducer = useSelector((selector: ReduxSelector) => selector.config);
if (!apiKeyFromRedux) {
const storage = await browser.storage.sync.get({
apiKey: config.apiKey,
});
apiKey = storage.apiKey as string;
dispatch(setValue(apiKey));
}
if (!apiKey) {
await changeExtensionState('notSignedIn');
}
try {
const data = await WakaTimeCore.checkAuth(apiKey);
dispatch(setUser(data));
const items = await browser.storage.sync.get({ loggingEnabled: config.loggingEnabled });
if (items.loggingEnabled === true) {
await changeExtensionState('allGood');
} else {
await changeExtensionState('notLogging');
}
const totalTimeLoggedToday = await WakaTimeCore.getTotalTimeLoggedToday(apiKey);
setState({
...state,
loggedIn: true,
loggingEnabled: items.loggingEnabled as boolean,
totalTimeLoggedToday: totalTimeLoggedToday.text,
});
await WakaTimeCore.recordHeartbeat();
} catch (err: unknown) {
await changeExtensionState('notSignedIn');
}
};
useEffect(() => { useEffect(() => {
fetchUserData(); fetchUserData(apiKeyFromRedux, dispatch);
}, []); }, []);
const disableLogging = async () => { const disableLogging = async () => {
@@ -109,10 +68,9 @@ export default function WakaTime(): JSX.Element {
<MainList <MainList
disableLogging={disableLogging} disableLogging={disableLogging}
enableLogging={enableLogging} enableLogging={enableLogging}
loggingEnabled={state.loggingEnabled} loggingEnabled={loggingEnabled}
totalTimeLoggedToday={state.totalTimeLoggedToday} totalTimeLoggedToday={totalTimeLoggedToday}
logoutUser={logoutUser} logoutUser={logoutUser}
loggedIn={state.loggedIn}
/> />
</div> </div>
</div> </div>

View File

@@ -1,25 +0,0 @@
import { createSlice } from '@reduxjs/toolkit';
interface setValueAction {
payload: string;
type: string;
}
export interface ApiKey {
value: string;
}
export const initialState: ApiKey = { value: '' };
const apiKeySlice = createSlice({
initialState,
name: 'spiKey',
reducers: {
setValue: (state, action: setValueAction) => {
state.value = action.payload;
},
},
});
export const actions = apiKeySlice.actions;
export const { setValue } = apiKeySlice.actions;
export default apiKeySlice.reducer;

View File

@@ -0,0 +1,50 @@
import { createSlice } from '@reduxjs/toolkit';
import config from '../config/config';
import { ApiKeyReducer } from '../types/store';
interface SetApiKeyAction {
payload: string;
type: string;
}
interface SetLoggingEnabledAction {
payload: boolean;
type: string;
}
interface SetTotalTimeLoggedTodayAction {
payload: string;
type: string;
}
export const initialConfigState: ApiKeyReducer = {
apiKey: '',
loggingEnabled: config.loggingEnabled,
totalTimeLoggedToday: '0 minutes',
};
const apiKeySlice = createSlice({
initialState: initialConfigState,
name: 'configReducer',
reducers: {
configLogout: (state) => {
state.apiKey = '';
state.loggingEnabled = config.loggingEnabled;
state.totalTimeLoggedToday = '0 minutes';
},
setApiKey: (state, action: SetApiKeyAction) => {
state.apiKey = action.payload;
},
setLoggingEnabled: (state, action: SetLoggingEnabledAction) => {
state.loggingEnabled = action.payload;
},
setTotalTimeLoggedToday: (state, action: SetTotalTimeLoggedTodayAction) => {
state.totalTimeLoggedToday = action.payload;
},
},
});
export const actions = apiKeySlice.actions;
export const { configLogout, setApiKey, setLoggingEnabled, setTotalTimeLoggedToday } =
apiKeySlice.actions;
export default apiKeySlice.reducer;

View File

@@ -39,9 +39,12 @@ const currentUser = createSlice({
setUser: (state, action: setUserAction) => { setUser: (state, action: setUserAction) => {
state.user = action.payload; state.user = action.payload;
}, },
userLogout: (state) => {
state.user = undefined;
},
}, },
}); });
export const actions = currentUser.actions; export const actions = currentUser.actions;
export const { setUser } = currentUser.actions; export const { setUser, userLogout } = currentUser.actions;
export default currentUser.reducer; export default currentUser.reducer;

View File

@@ -1,22 +1,25 @@
import { configureStore, Store } from '@reduxjs/toolkit'; import { configureStore, Store, combineReducers } from '@reduxjs/toolkit';
import { logger } from 'redux-logger'; import { logger } from 'redux-logger';
import { reduxBatch } from '@manaflair/redux-batch'; import { reduxBatch } from '@manaflair/redux-batch';
import devToolsEnhancer from 'remote-redux-devtools'; import devToolsEnhancer from 'remote-redux-devtools';
import currentUserReducer, { initialState as InitalCurrentUser } from '../reducers/currentUser'; import currentUserReducer, { initialState as InitalCurrentUser } from '../reducers/currentUser';
import apiKeyReducer from '../reducers/apiKey'; import configReducer, { initialConfigState } from '../reducers/configReducer';
import isProd from '../utils/isProd'; import isProd from '../utils/isProd';
import { CurrentUser } from '../types/user';
export interface RootState { // Create the root reducer separately so we can extract the RootState type
currentUser: CurrentUser; const rootReducer = combineReducers({
} config: configReducer,
currentUser: currentUserReducer,
});
export type RootState = ReturnType<typeof rootReducer>;
const preloadedState: RootState = { const preloadedState: RootState = {
config: initialConfigState,
currentUser: InitalCurrentUser, currentUser: InitalCurrentUser,
}; };
export type RootStore = Store<RootState>; export default (appName: string): Store<RootState> => {
export default (appName: string): RootStore => {
const enhancers = []; const enhancers = [];
enhancers.push(reduxBatch); enhancers.push(reduxBatch);
if (!isProd()) { if (!isProd()) {
@@ -29,10 +32,7 @@ export default (appName: string): RootStore => {
enhancers, enhancers,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger), middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
preloadedState, preloadedState,
reducer: { reducer: rootReducer,
apiKey: apiKeyReducer,
currentUser: currentUserReducer,
},
}); });
return store; return store;

View File

@@ -1,10 +1,12 @@
import { CurrentUser } from './user'; import { CurrentUser } from './user';
export interface ApiKeyReducer { export interface ApiKeyReducer {
value: string; apiKey: string;
loggingEnabled: boolean;
totalTimeLoggedToday: string;
} }
export interface ReduxSelector { export interface ReduxSelector {
apiKey: ApiKeyReducer; config: ApiKeyReducer;
currentUser: CurrentUser; currentUser: CurrentUser;
} }

View File

@@ -1,12 +1,13 @@
import { RootStore } from '../stores/createStore'; import { Store } from '@reduxjs/toolkit';
import { RootState } from '../stores/createStore';
import { fetchCurrentUser } from '../reducers/currentUser'; import { fetchCurrentUser } from '../reducers/currentUser';
import { ReduxSelector } from '../types/store'; import { ReduxSelector } from '../types/store';
type unsub = () => void; type unsub = () => void;
export default (store: RootStore) => export default (store: Store<RootState>) =>
(time: number): unsub => { (time: number): unsub => {
const fetchUser = () => { const fetchUser = () => {
const apiKey: string = (store.getState() as ReduxSelector).apiKey.value; const apiKey: string = (store.getState() as ReduxSelector).config.apiKey;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error // @ts-expect-error
store.dispatch(fetchCurrentUser(apiKey)); store.dispatch(fetchCurrentUser(apiKey));

43
src/utils/test-utils.tsx Normal file
View File

@@ -0,0 +1,43 @@
import React, { PropsWithChildren } from 'react';
import { render } from '@testing-library/react';
import type { RenderOptions } from '@testing-library/react';
import { combineReducers, configureStore, Store } from '@reduxjs/toolkit';
import type { PreloadedState } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import { RootState } from '../stores/createStore';
// As a basic setup, import your same slice reducers
import userReducer, { initialState as InitalCurrentUser } from '../reducers/currentUser';
import configReducer, { initialConfigState } from '../reducers/configReducer';
// This type interface extends the default options for render from RTL, as well
// as allows the user to specify other things such as initialState, store.
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: PreloadedState<RootState>;
store?: Store<RootState>;
}
const rootReducer = combineReducers({
config: configReducer,
currentUser: userReducer,
});
export function renderWithProviders(
ui: React.ReactElement,
{
preloadedState = {
config: initialConfigState,
currentUser: InitalCurrentUser,
},
// Automatically create a store instance if no store was passed in
store = configureStore({ preloadedState, reducer: rootReducer }),
...renderOptions
}: ExtendedRenderOptions = {},
): any {
function Wrapper({ children }: PropsWithChildren<Record<string, unknown>>): JSX.Element {
return <Provider store={store}>{children}</Provider>;
}
// Return an object with the store and all of RTL's query functions
return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
}

45
src/utils/user.ts Normal file
View File

@@ -0,0 +1,45 @@
import { AnyAction, Dispatch } from '@reduxjs/toolkit';
import { setApiKey, setLoggingEnabled, setTotalTimeLoggedToday } from '../reducers/configReducer';
import config from '../config/config';
import WakaTimeCore from '../core/WakaTimeCore';
import { setUser } from '../reducers/currentUser';
import changeExtensionState from './changeExtensionState';
export const fetchUserData = async (
apiKey: string,
dispatch: Dispatch<AnyAction>,
): Promise<void> => {
if (!apiKey) {
const storage = await browser.storage.sync.get({
apiKey: config.apiKey,
});
apiKey = storage.apiKey as string;
dispatch(setApiKey(apiKey));
}
if (!apiKey) {
await changeExtensionState('notSignedIn');
}
try {
const [data, totalTimeLoggedTodayResponse, items] = await Promise.all([
WakaTimeCore.checkAuth(apiKey),
WakaTimeCore.getTotalTimeLoggedToday(apiKey),
browser.storage.sync.get({ loggingEnabled: config.loggingEnabled }),
]);
dispatch(setUser(data));
if (items.loggingEnabled === true) {
await changeExtensionState('allGood');
} else {
await changeExtensionState('notLogging');
}
dispatch(setLoggingEnabled(items.loggingEnabled as boolean));
dispatch(setTotalTimeLoggedToday(totalTimeLoggedTodayResponse.text));
await WakaTimeCore.recordHeartbeat();
} catch (err: unknown) {
await changeExtensionState('notSignedIn');
}
};