diff --git a/template/.prettierrc.js b/template/.prettierrc.js index bd329d6..065698b 100644 --- a/template/.prettierrc.js +++ b/template/.prettierrc.js @@ -16,6 +16,7 @@ module.exports = { '^@localization/(.*)$', '^@navigation/(.*)$', '^@redux/(.*)$', + '^@remote/(.*)$', '^@screens/(.*)$', '^@utils/(.*)$', '^[./]', diff --git a/template/README.md b/template/README.md index e42c651..ed1e2b3 100644 --- a/template/README.md +++ b/template/README.md @@ -30,6 +30,7 @@ This app has been generated using [react-native-template-redbeard](https://githu - `localization/` - Things related to user locale - `navigation/` - Navigators, routes - `redux/` - Actions, reducers, sagas +- `remote/` - Remote state (via TanStack Query) - `screens/` - App screens - `utils/` - Universal helpers diff --git a/template/babel.config.js b/template/babel.config.js index f165bc9..c87b96f 100644 --- a/template/babel.config.js +++ b/template/babel.config.js @@ -31,6 +31,7 @@ module.exports = { '@localization': './src/localization', '@navigation': './src/navigation', '@redux': './src/redux', + '@remote': './src/remote', '@screens': './src/screens', '@utils': './src/utils', }, diff --git a/template/package.json b/template/package.json index b6e347d..43bfb91 100644 --- a/template/package.json +++ b/template/package.json @@ -24,6 +24,7 @@ "@react-navigation/native": "^6.0.10", "@react-navigation/native-stack": "^6.6.2", "@reduxjs/toolkit": "^1.6.1", + "@tanstack/react-query": "5.0.0-beta.15", "dayjs": "^1.10.6", "i18next": "^20.3.5", "react": "18.2.0", @@ -53,8 +54,8 @@ "@babel/runtime": "^7.20.0", "@jambit/eslint-plugin-typed-redux-saga": "^0.4.0", "@react-native-community/eslint-config": "^3.2.0", - "@testing-library/jest-native": "^4.0.1", - "@testing-library/react-native": "^7.2.0", + "@testing-library/jest-native": "^5.4.2", + "@testing-library/react-native": "^12.2.2", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@tsconfig/react-native": "^2.0.2", "@types/jest": "^29.2.1", diff --git a/template/src/App.tsx b/template/src/App.tsx index c93c04f..e295174 100644 --- a/template/src/App.tsx +++ b/template/src/App.tsx @@ -1,4 +1,5 @@ import { NavigationContainer } from '@react-navigation/native' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import StoreProvider from 'providers/StoreProvider' import React from 'react' import 'react-native-gesture-handler' @@ -7,15 +8,19 @@ import SplashScreen from 'react-native-splash-screen' import '@localization/i18n' import RootStackNavigator from '@navigation/navigators/RootStackNavigator' +const queryClient = new QueryClient() + const App = () => { return ( - - - - - - - + + + + + + + + + ) } diff --git a/template/src/api/authSlice.test.ts b/template/src/api/authSlice.test.ts index 05db540..430e51b 100644 --- a/template/src/api/authSlice.test.ts +++ b/template/src/api/authSlice.test.ts @@ -1,44 +1,21 @@ import { REHYDRATE } from 'redux-persist' import { expectSaga } from 'redux-saga-test-plan' -import * as matchers from 'redux-saga-test-plan/matchers' -import { throwError } from 'redux-saga-test-plan/providers' import { resetStore } from '@redux/rootActions' -import { Failure, Loading, RemoteDataStates, Success } from '@utils/api' -import { logIn } from './auth' -import { - authSlice, - logInAsync, - logInAsyncFailure, - logInAsyncSuccess, - watchAuthTokens, - watchLogInSaga, -} from './authSlice' +import { authSlice, logInAsyncSuccess, watchAuthTokens } from './authSlice' import { setAuthConfig } from './common' const fakeAuthTokens = { accessToken: 'FAKE_ACCESS_TOKEN', refreshToken: 'FAKE_REFRESH_TOKEN', } -const fakeCredentials = { username: 'FAKE_USERNAME', password: 'FAKE_PASSWORD' } -const logInErrorMessage = 'Login failed' describe('#watchAuthTokens', () => { - it('should set API auth tokens on successful login', () => { - return expectSaga(watchAuthTokens) - .call(setAuthConfig, fakeAuthTokens) - .dispatch(logInAsyncSuccess(fakeAuthTokens)) - .silentRun() - }) - it('should restore API auth tokens on REHYDRATE auth action', () => { const rehydrateAction = { type: REHYDRATE, key: 'auth', payload: { - tokens: { - data: fakeAuthTokens, - type: RemoteDataStates.SUCCESS, - }, + tokens: fakeAuthTokens, _persist: { rehydrated: true, version: -1, @@ -63,45 +40,13 @@ describe('#watchAuthTokens', () => { }) }) -describe('#watchLogInSaga', () => { - it('should log user in and put success action with tokens', () => { - return expectSaga(watchLogInSaga) - .provide([[matchers.call.fn(logIn), fakeAuthTokens]]) - .put(logInAsyncSuccess(fakeAuthTokens)) - .dispatch(logInAsync(fakeCredentials)) - .silentRun() - }) - - it('should put log in error action wit error message on auth error', () => { - return expectSaga(watchLogInSaga) - .provide([[matchers.call.fn(logIn), throwError(new Error(logInErrorMessage))]]) - .put(logInAsyncFailure(logInErrorMessage)) - .dispatch(logInAsync(fakeCredentials)) - .silentRun() - }) -}) - describe('#authSlice', () => { const initialState = authSlice.getInitialState() - describe('#loginAsync', () => { - it('should change tokens state to Loading', () => { - const state = authSlice.reducer(initialState, logInAsync(fakeCredentials)) - expect(state.tokens).toEqual(Loading) - }) - }) - describe('#logInAsyncSuccess', () => { it('should store auth tokens', () => { const state = authSlice.reducer(initialState, logInAsyncSuccess(fakeAuthTokens)) - expect(state.tokens).toEqual(Success(fakeAuthTokens)) - }) - }) - - describe('#logInAsyncFailure', () => { - it('should store auth error message', () => { - const state = authSlice.reducer(initialState, logInAsyncFailure(logInErrorMessage)) - expect(state.tokens).toEqual(Failure(logInErrorMessage)) + expect(state.tokens).toEqual(fakeAuthTokens) }) }) }) diff --git a/template/src/api/authSlice.ts b/template/src/api/authSlice.ts index dc9c14a..ad2daff 100644 --- a/template/src/api/authSlice.ts +++ b/template/src/api/authSlice.ts @@ -1,15 +1,11 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit' import { REHYDRATE, persistReducer } from 'redux-persist' import { PersistPartial } from 'redux-persist/es/persistReducer' -import { call, put, takeLatest, takeLeading } from 'typed-redux-saga' -import { logIn as logInRequest } from '@api/auth' +import { call, takeLatest } from 'typed-redux-saga' import { AuthTokens, setAuthConfig } from '@api/common' -import { Credentials } from '@api/types/auth.types' import { safeStorage } from '@redux/persistence' import { resetStore } from '@redux/rootActions' import { RootState } from '@redux/store' -import { Failure, Loading, NotRequested, RemoteData, Success, isSuccess } from '@utils/api' -import { getErrorMessage } from '@utils/error' type TopLevelStoreStates = { [K in keyof RootState]: RootState[K] @@ -26,23 +22,18 @@ interface RehydrateAction { payload?: RehydratePayload } -function* setApiAuthConfig( - action: ReturnType | ReturnType | RehydrateAction, -) { - const isLoginAction = logInAsyncSuccess.match(action) +function* setApiAuthConfig(action: ReturnType | RehydrateAction) { const isResetStoreAction = resetStore.match(action) - if (isLoginAction) { - yield* call(setAuthConfig, action.payload) - } else if (isResetStoreAction) { + if (isResetStoreAction) { yield* call(setAuthConfig, { accessToken: undefined, refreshToken: undefined }) } else if ( action.key === authPersistConfig.key && action.payload && 'tokens' in action.payload && - isSuccess(action.payload.tokens) + action.payload.tokens ) { - yield* call(setAuthConfig, action.payload.tokens.data) + yield* call(setAuthConfig, action.payload.tokens) } } @@ -50,50 +41,31 @@ export function* watchAuthTokens() { yield* takeLatest([logInAsyncSuccess, REHYDRATE, resetStore], setApiAuthConfig) } -function* logIn(action: ReturnType) { - try { - const { accessToken, refreshToken } = yield* call(logInRequest, action.payload) - yield* put(logInAsyncSuccess({ accessToken, refreshToken })) - } catch (error) { - yield* put(logInAsyncFailure(getErrorMessage(error))) - } -} - -export function* watchLogInSaga() { - yield* takeLeading(logInAsync, logIn) -} - interface AuthState { - tokens: RemoteData + tokens: AuthTokens | undefined } const initialState: AuthState = { - tokens: NotRequested, + tokens: undefined, } export const authSlice = createSlice({ name: 'auth', initialState, reducers: { - logInAsync: (state, _action: PayloadAction) => { - state.tokens = Loading - }, logInAsyncSuccess: (state, action: PayloadAction) => { - state.tokens = Success(action.payload) - }, - logInAsyncFailure: (state, action: PayloadAction) => { - state.tokens = Failure(action.payload) + state.tokens = action.payload }, }, }) -export const { logInAsync, logInAsyncSuccess, logInAsyncFailure } = authSlice.actions +export const { logInAsyncSuccess } = authSlice.actions export const selectAuthTokens = (state: RootState) => state.auth.tokens export const selectIsLoggedIn = (state: RootState) => { const { tokens } = state.auth - return isSuccess(tokens) && Boolean(tokens.data.accessToken) + return !!tokens?.accessToken } const authPersistConfig = { diff --git a/template/src/redux/rootSaga.ts b/template/src/redux/rootSaga.ts index 859c6cf..451004f 100644 --- a/template/src/redux/rootSaga.ts +++ b/template/src/redux/rootSaga.ts @@ -1,7 +1,6 @@ import { all } from 'typed-redux-saga' -import { watchAuthTokens, watchLogInSaga } from '@api/authSlice' -import { watchGetLatestComicSaga } from '@screens/demoSlice' +import { watchAuthTokens } from '@api/authSlice' export default function* rootSaga() { - yield* all([watchAuthTokens(), watchLogInSaga(), watchGetLatestComicSaga()]) + yield* all([watchAuthTokens()]) } diff --git a/template/src/remote/auth.test.ts b/template/src/remote/auth.test.ts new file mode 100644 index 0000000..50d6e14 --- /dev/null +++ b/template/src/remote/auth.test.ts @@ -0,0 +1,101 @@ +import { renderHook, waitFor } from '@testing-library/react-native' +import { logIn } from '@api/auth' +import { logInAsyncSuccess } from '@api/authSlice' +import { setAuthConfig } from '@api/common' +import * as persistence from '@redux/persistence' +import { resetStore } from '@redux/rootActions' +import { persistor } from '@redux/store' +import { createTestEnvWrapper } from '@utils/testing' +import { useLogInMutation, useLogOutMutation } from './auth' + +const mockCredentials = { + username: 'testUsername', + password: 'testPassword', +} + +const mockTokens = { + accessToken: 'testAccessToken', + refreshToken: 'testRefreshToken', +} + +jest.mock('@api/auth', () => ({ + logIn: jest.fn(), +})) +const mockLogIn = logIn as jest.MockedFunction +mockLogIn.mockResolvedValue(mockTokens) + +jest.mock('@api/common', () => ({ + setAuthConfig: jest.fn(), +})) +const mockSetAuthConfig = setAuthConfig as jest.MockedFunction + +const mockDispatch = jest.fn() +jest.mock('@hooks/useAppDispatch', () => ({ + __esModule: true, + default: jest.fn(() => { + return mockDispatch + }), +})) + +jest.mock('@redux/store', () => ({ + persistor: { + pause: jest.fn(), + persist: jest.fn(), + }, +})) + +jest.useFakeTimers() + +describe('auth', () => { + let wrapper: ReturnType + + beforeEach(() => { + jest.clearAllMocks() + wrapper = createTestEnvWrapper({}) + }) + + describe('useLogInMutation', () => { + it('calls onSuccess', async () => { + const { result } = renderHook(() => useLogInMutation(), { wrapper }) + + result.current.mutate(mockCredentials) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current).toMatchObject({ + isSuccess: true, + data: mockTokens, + }) + + expect(mockSetAuthConfig).toHaveBeenCalledTimes(1) + expect(mockSetAuthConfig).toHaveBeenLastCalledWith(mockTokens) + expect(mockDispatch).toHaveBeenCalledTimes(1) + expect(mockDispatch).toHaveBeenLastCalledWith(logInAsyncSuccess(mockTokens)) + }) + }) + + describe('useLogOutMutation', () => { + it('calls onSuccess', async () => { + const mockClearPersistence = jest.spyOn(persistence, 'clearPersistence') + + const { result } = renderHook(() => useLogOutMutation(), { wrapper }) + + result.current.mutate() + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current).toMatchObject({ + isSuccess: true, + data: undefined, + }) + + expect(persistor.pause).toHaveBeenCalledTimes(1) + expect(mockClearPersistence).toHaveBeenCalledTimes(1) + expect(mockSetAuthConfig).toHaveBeenCalledTimes(1) + expect(mockSetAuthConfig).toHaveBeenLastCalledWith({}) + expect(mockDispatch).toHaveBeenCalledTimes(1) + expect(mockDispatch).toHaveBeenLastCalledWith(resetStore()) + expect(persistor.persist).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/template/src/remote/auth.ts b/template/src/remote/auth.ts new file mode 100644 index 0000000..df3f699 --- /dev/null +++ b/template/src/remote/auth.ts @@ -0,0 +1,43 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { logIn } from '@api/auth' +import { logInAsyncSuccess } from '@api/authSlice' +import { setAuthConfig } from '@api/common' +import useAppDispatch from '@hooks/useAppDispatch' +import { clearPersistence } from '@redux/persistence' +import { resetStore } from '@redux/rootActions' +import { persistor } from '@redux/store' + +export const useLogInMutation = () => { + const dispatch = useAppDispatch() + + return useMutation({ + mutationKey: ['auth', 'logIn'], + mutationFn: logIn, + onSuccess: tokens => { + setAuthConfig(tokens) + dispatch(logInAsyncSuccess(tokens)) + }, + }) +} + +export const useLogOutMutation = () => { + const dispatch = useAppDispatch() + const client = useQueryClient() + + return useMutation({ + mutationKey: ['auth', 'logOut'], + mutationFn: async () => { + Promise.resolve() + }, + onSuccess: async () => { + // typical logout for apps that require user to be logged in + // before giving any further access + persistor.pause() + await clearPersistence() + setAuthConfig({}) + dispatch(resetStore()) + client.clear() + persistor.persist() + }, + }) +} diff --git a/template/src/remote/comics.test.ts b/template/src/remote/comics.test.ts new file mode 100644 index 0000000..db897f6 --- /dev/null +++ b/template/src/remote/comics.test.ts @@ -0,0 +1,64 @@ +import { renderHook, waitFor } from '@testing-library/react-native' +import { getLatestComic } from '@api/comics' +import { Comic, ComicBE } from '@api/types/comic.types' +import { createTestEnvWrapper } from '@utils/testing' +import { useLatestComicQuery } from './comics' + +const mockComicBE: ComicBE = { + num: 1, + title: 'test title', + alt: 'test description', + img: 'test image url', +} as ComicBE + +const mockComic: Comic = { + id: 1, + title: 'test title', + description: 'test description', + imageUrl: 'test image url', +} + +const mockGetLatestComic = getLatestComic as jest.MockedFunction + +jest.mock('@api/comics', () => ({ + getLatestComic: jest.fn().mockRejectedValue(mockComicBE), +})) + +jest.useFakeTimers() + +describe('comics', () => { + let wrapper: ReturnType + + beforeEach(() => { + jest.clearAllMocks() + wrapper = createTestEnvWrapper({}) + }) + + describe('useLatestComicQuery', () => { + it('is initially loading', async () => { + const { result } = renderHook(() => useLatestComicQuery(), { wrapper }) + + expect(result.current).toMatchObject({ + isLoading: true, + isSuccess: false, + data: undefined, + }) + }) + + it('should fetch the latest comic', async () => { + mockGetLatestComic.mockResolvedValueOnce(mockComicBE) + + const { result } = renderHook(() => useLatestComicQuery(), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(getLatestComic).toHaveBeenCalledTimes(1) + + expect(result.current).toMatchObject({ + data: mockComic, + isLoading: false, + isSuccess: true, + }) + }) + }) +}) diff --git a/template/src/remote/comics.ts b/template/src/remote/comics.ts new file mode 100644 index 0000000..2d71894 --- /dev/null +++ b/template/src/remote/comics.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' +import { getLatestComic } from '@api/comics' +import { mapComic } from '@api/mappers/comicMappers' + +export const useLatestComicQuery = () => { + return useQuery({ + queryKey: ['comic', 'latest'], + queryFn: getLatestComic, + select: comicBE => mapComic(comicBE), + }) +} diff --git a/template/src/screens/DemoScreen.test.tsx b/template/src/screens/DemoScreen.test.tsx index 2932dfd..f559f41 100644 --- a/template/src/screens/DemoScreen.test.tsx +++ b/template/src/screens/DemoScreen.test.tsx @@ -1,13 +1,26 @@ +import { comicMockParsed } from '__mocks__/fixtures' import React from 'react' import { TestIDs } from '@config/testIDs' import Routes from '@navigation/routes' -import { RemoteDataStates } from '@utils/api' -import { createNavigationProps, fireEvent, render } from '@utils/testing' +import * as remoteComics from '@remote/comics' +import { act, createNavigationProps, fireEvent, render } from '@utils/testing' import DemoScreen from './DemoScreen' // eslint-disable-next-line @typescript-eslint/no-explicit-any const navPropsMock = createNavigationProps() as any +const mockUseLatestComicQuery = jest.spyOn(remoteComics, 'useLatestComicQuery').mockReturnValue({ + isLoading: false, + data: undefined, +} as ReturnType) + +jest.mock('@remote/auth', () => ({ + useLogOutMutation: jest.fn().mockImplementation(() => ({ + mutate: jest.fn(), + isPending: false, + })), +})) + describe('when increment button pressed', () => { it('should increment counter by 5', () => { const { getByText } = render() @@ -15,7 +28,10 @@ describe('when increment button pressed', () => { getByText(/demoScreen.counter/).props.children.split(' ')[1], 10, ) - fireEvent.press(getByText(/demoScreen.incrementButton/)) + act(() => { + fireEvent.press(getByText(/demoScreen.incrementButton/)) + }) + const counterValue = parseInt(getByText(/demoScreen.counter/).props.children.split(' ')[1], 10) expect(counterValue).toBe(prevCounterValue + 5) @@ -29,7 +45,10 @@ describe('when decrement button pressed', () => { getByText(/demoScreen.counter/).props.children.split(' ')[1], 10, ) - fireEvent.press(getByText(/demoScreen.decrementButton/)) + + act(() => { + fireEvent.press(getByText(/demoScreen.decrementButton/)) + }) const counterValue = parseInt(getByText(/demoScreen.counter/).props.children.split(' ')[1], 10) expect(counterValue).toBe(prevCounterValue - 15) @@ -38,42 +57,41 @@ describe('when decrement button pressed', () => { describe('Comic card', () => { describe('when comic is available', () => { - it('renders the comic', () => { - const comicMock = { - id: 1, - title: 'Some mock title', - imageUrl: 'http://example.com/test.jpg', - description: 'Some mock description', - } + it('renders the comic', async () => { + mockUseLatestComicQuery.mockReturnValueOnce({ + isLoading: false, + data: comicMockParsed, + } as ReturnType) + const preloadedState = { demo: { counter: 420, - comic: { - state: RemoteDataStates.SUCCESS as const, - data: comicMock, - }, }, } + const { getByText, getByTestId } = render(, { preloadedState, }) - expect(getByText(comicMock.title)).toBeDefined() - expect(getByText(comicMock.description)).toBeDefined() + expect(getByText(comicMockParsed.title)).toBeDefined() + expect(getByText(comicMockParsed.description)).toBeDefined() expect(getByTestId(TestIDs.DEMO_COMIC_IMAGE)).toBeDefined() }) }) describe('when NO comic is available', () => { + mockUseLatestComicQuery.mockReturnValue({ + isLoading: true, + data: undefined, + } as ReturnType) + it('renders the loading spinner', () => { const preloadedState = { demo: { counter: 420, - comic: { - state: RemoteDataStates.LOADING as const, - }, }, } + const { getByTestId } = render(, { preloadedState, }) @@ -86,7 +104,9 @@ describe('Comic card', () => { describe('when "go to translations demo" pressed', () => { it('should navigate to translations demo screen', () => { const { getByText } = render() - fireEvent.press(getByText(/demoScreen.goToTranslationsDemo/)) + act(() => { + fireEvent.press(getByText(/demoScreen.goToTranslationsDemo/)) + }) expect(navPropsMock.navigation.navigate).toBeCalledWith(Routes.TRANSLATIONS_DEMO_SCREEN) }) diff --git a/template/src/screens/DemoScreen.tsx b/template/src/screens/DemoScreen.tsx index 90a3ddd..bebbf62 100644 --- a/template/src/screens/DemoScreen.tsx +++ b/template/src/screens/DemoScreen.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' import { ActivityIndicator, Button, Image, StyleSheet, Text } from 'react-native' import MainScreenLayout from '@components/layouts/MainScreenLayout' @@ -9,17 +9,9 @@ import useAppDispatch from '@hooks/useAppDispatch' import useAppSelector from '@hooks/useAppSelector' import type { RootStackScreenProps } from '@navigation/navigators/RootStackNavigator' import Routes from '@navigation/routes' -import { clearPersistence } from '@redux/persistence' -import { resetStore } from '@redux/rootActions' -import { persistor } from '@redux/store' -import { hasData, isNotRequested } from '@utils/api' -import { - decrementCounterBy, - getLatestComicAsync, - incrementCounterBy, - selectComic, - selectCounter, -} from './demoSlice' +import { useLogOutMutation } from '@remote/auth' +import { useLatestComicQuery } from '@remote/comics' +import { decrementCounterBy, incrementCounterBy, selectCounter } from './demoSlice' export type DemoScreenParams = undefined @@ -29,33 +21,13 @@ interface DemoScreenProps { } const DemoScreen = ({ navigation }: DemoScreenProps) => { - const [isLogoutLoading, setIsLogoutLoading] = useState(false) const counter = useAppSelector(selectCounter) - const comicRequest = useAppSelector(selectComic) const dispatch = useAppDispatch() const { t } = useTranslation() - useEffect(() => { - if (isNotRequested(comicRequest)) { - dispatch(getLatestComicAsync()) - } - }, [comicRequest.state]) + const { isLoading, data: comicData } = useLatestComicQuery() - const logOut = async () => { - try { - setIsLogoutLoading(true) - // typical logout for apps that require user to be logged in - // before giving any further access - persistor.pause() - await clearPersistence() - dispatch(resetStore()) - persistor.persist() - } finally { - setIsLogoutLoading(false) - } - } - - const comicData = hasData(comicRequest) ? comicRequest.data : null + const { mutate: logOut, isPending: isLogoutLoading } = useLogOutMutation() return ( @@ -71,7 +43,7 @@ const DemoScreen = ({ navigation }: DemoScreenProps) => { {`${t('demoScreen.counter')} ${counter}`} - {comicData ? ( + {!isLoading && comicData ? ( <> {comicData.title} { {isLogoutLoading ? ( ) : ( -