From ecbeecab2310acbd5e18ceb7f9466e0ebab6ffec Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Fri, 31 Jul 2026 03:16:32 +0200 Subject: [PATCH] Replace redux with TanStack Query and an auth context --- README.md | 10 +- jest.config.js | 2 +- jest.setup.js | 5 + package-lock.json | 132 +++++----- package.json | 13 +- src/App.test.tsx | 22 +- src/App.tsx | 18 +- src/AppInner.tsx | 4 +- src/actions.ts | 135 ---------- src/api.test.ts | 202 +++++++-------- src/api.ts | 272 +++++++-------------- src/auth/AuthContext.test.tsx | 99 ++++++++ src/auth/AuthContext.tsx | 87 +++++++ src/auth/migrateFromReduxPersist.test.ts | 135 ++++++++++ src/auth/migrateFromReduxPersist.ts | 76 ++++++ src/auth/tokenStorage.test.ts | 26 ++ src/auth/tokenStorage.ts | 15 ++ src/components/FormErrors.tsx | 16 +- src/errors.ts | 15 ++ src/hooks.tsx | 48 +--- src/modules/events/EventList.test.tsx | 21 +- src/modules/events/useEventList.tsx | 50 +--- src/modules/login/useLogin.tsx | 23 +- src/modules/membership/Membership.test.tsx | 55 +++-- src/modules/membership/Membership.tsx | 22 +- src/modules/membership/Proof.test.tsx | 6 +- src/modules/membership/Proof.tsx | 6 +- src/modules/membership/UserDelete.tsx | 17 +- src/modules/membership/useMembership.tsx | 30 +-- src/modules/membership/useProof.tsx | 13 +- src/modules/membership/useUserDelete.tsx | 30 +-- src/modules/register/useUserRegister.tsx | 68 ++---- src/queries/queryClient.test.ts | 43 ++++ src/queries/queryClient.ts | 41 ++++ src/queries/queryKeys.ts | 4 + src/queries/useDeleteUserMutation.ts | 13 + src/queries/useEvents.ts | 12 + src/queries/useLoginMutation.ts | 18 ++ src/queries/usePaymentSheetMutation.ts | 12 + src/queries/useRegisterUserMutation.ts | 20 ++ src/queries/useUser.ts | 31 +++ src/reducers.ts | 101 -------- src/reduxStore.ts | 25 -- src/testUtils.tsx | 47 ++-- src/types/forms.ts | 7 + 45 files changed, 1141 insertions(+), 906 deletions(-) delete mode 100644 src/actions.ts create mode 100644 src/auth/AuthContext.test.tsx create mode 100644 src/auth/AuthContext.tsx create mode 100644 src/auth/migrateFromReduxPersist.test.ts create mode 100644 src/auth/migrateFromReduxPersist.ts create mode 100644 src/auth/tokenStorage.test.ts create mode 100644 src/auth/tokenStorage.ts create mode 100644 src/errors.ts create mode 100644 src/queries/queryClient.test.ts create mode 100644 src/queries/queryClient.ts create mode 100644 src/queries/queryKeys.ts create mode 100644 src/queries/useDeleteUserMutation.ts create mode 100644 src/queries/useEvents.ts create mode 100644 src/queries/useLoginMutation.ts create mode 100644 src/queries/usePaymentSheetMutation.ts create mode 100644 src/queries/useRegisterUserMutation.ts create mode 100644 src/queries/useUser.ts delete mode 100644 src/reducers.ts delete mode 100644 src/reduxStore.ts create mode 100644 src/types/forms.ts diff --git a/README.md b/README.md index 18c7ae6..b848ef6 100644 --- a/README.md +++ b/README.md @@ -98,12 +98,8 @@ history, code and binary blobs related to React Native functionality filtered ou [Bugsink ≥ 2.0.14](https://www.bugsink.com/docs/sourcemaps/); verify the project slug in Bugsink matches `cnapp` - test in ios emulator? (`npm run ios`, requires macOS) +- characters in the password input field are invisible - use [Expo Application Services](https://expo.dev/eas) to build and submit app to stores -- replace redux (`redux` v5 + `redux-thunk`, hand-written actions and the deprecated - `createStore`): nearly everything in the store is server state (user data) plus one auth - token, so drop redux for [TanStack Query](https://tanstack.com/query/latest) for - fetching/caching, with the token in React context + AsyncStorage — replaces - `src/reducers.ts`, the fetch thunks in `src/api.ts` and the `isFetching*`/error flags - follow-ups from the [Expo SDK 54 upgrade](https://expo.dev/changelog/sdk-54): - try the [React Compiler](https://docs.expo.dev/guides/react-compiler/) (enabled by default in new SDK 54 projects, opt-in for existing ones via @@ -118,10 +114,6 @@ history, code and binary blobs related to React Native functionality filtered ou - try [`@expo/ui`](https://docs.expo.dev/versions/latest/sdk/ui/), production-ready as of SDK 56: real SwiftUI / Jetpack Compose components that could back the homegrown ones in `src/components/` and finally retire `react-native-simple-radio-button` - - swap `@react-native-async-storage/async-storage` (used by `redux-persist` in - `src/reduxStore.ts`) for - [`expo-sqlite/localStorage`](https://docs.expo.dev/versions/latest/sdk/sqlite/#localstorage), - ideally folded into the redux replacement above - verify on iOS: the minimum target jumped to iOS 16.4 (from 15.1), dropping the iPhone 7, 6s and SE (1st gen). Building now requires Xcode 26.4 on macOS 13.4+ - add [Apple Pay](https://docs.stripe.com/apple-pay?platform=react-native) for membership diff --git a/jest.config.js b/jest.config.js index 278b53a..041ec5e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,7 +11,7 @@ module.exports = { }, transformIgnorePatterns: [ // jest-expo's default list, extended with untranspiled libraries this app uses - 'node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|react-native-ui-lib|uilib-native|react-redux))', + 'node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|react-native-ui-lib|uilib-native))', '/node_modules/react-native-reanimated/plugin/', ], collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.test.{ts,tsx}', '!src/types/**', '!src/testUtils.tsx'], diff --git a/jest.setup.js b/jest.setup.js index bbe87b0..becc0d6 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -18,6 +18,11 @@ global.fetch = jest.fn(async () => ({ json: async () => ({ events: [] }), })); +// TanStack Query batches subscriber notifications on a setTimeout; deliver +// them synchronously so query updates stay inside the act() that caused them +const { notifyManager } = require('@tanstack/react-query'); +notifyManager.setScheduler((callback) => callback()); + jest.mock('@react-native-async-storage/async-storage', () => require('@react-native-async-storage/async-storage/jest/async-storage-mock'), ); diff --git a/package-lock.json b/package-lock.json index e21f022..61e9daf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,9 @@ "@sentry/react-native": "~8.20.0", "@shopify/react-native-skia": "2.6.2", "@stripe/stripe-react-native": "0.64.0", + "@tanstack/query-async-storage-persister": "^5.101.4", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query-persist-client": "^5.101.4", "date-fns": "^4.4.0", "email-validator": "^2.0.4", "expo": "~57.0.9", @@ -39,11 +42,7 @@ "react-native-screens": "~4.26.0", "react-native-svg": "15.15.4", "react-native-svg-transformer": "^1.5.3", - "react-native-worklets": "0.10.1", - "react-redux": "^9.2.0", - "redux": "^5.0.1", - "redux-persist": "^6.0.0", - "redux-thunk": "^3.1.0" + "react-native-worklets": "0.10.1" }, "devDependencies": { "@babel/core": "^7.24.9", @@ -3925,6 +3924,76 @@ "@svgr/core": "*" } }, + "node_modules/@tanstack/query-async-storage-persister": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.101.4.tgz", + "integrity": "sha512-ROenNOVvxIZ1zKsBAiIvvslLo2xD8Me/vFz/tChdmuI7ciU+ET63yRrQ01W899Tx1ayQXdsGQJqYqjjVZk+OBQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4", + "@tanstack/query-persist-client-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-persist-client-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.101.4.tgz", + "integrity": "sha512-Bmu+RfWhwYEyYZEwSeKC0gX4+KTkiEB3yO8oz9BfAY/Jfe3ndz9EnYVhihIZPmWIr8NXayF7JuraNQcrXQQCCg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-persist-client": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.101.4.tgz", + "integrity": "sha512-CY5i7PPKS/5B8OR2zAs9nCbLpgqI4jN8eBhtAxKRMNHyGcQhmyOy4PkkJrAAXT+ecGj++fBJTgQw6OpyCt/2SQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-persist-client-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.101.4", + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/react-native": { "version": "13.3.3", "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz", @@ -4178,12 +4247,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -13734,29 +13797,6 @@ "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", "license": "MIT" }, - "node_modules/react-redux": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", - "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -13794,30 +13834,6 @@ "node": ">=8" } }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" - }, - "node_modules/redux-persist": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", - "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", - "license": "MIT", - "peerDependencies": { - "redux": ">4.0.0" - } - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", diff --git a/package.json b/package.json index 9a2b99f..390d5f2 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "@sentry/react-native": "~8.20.0", "@shopify/react-native-skia": "2.6.2", "@stripe/stripe-react-native": "0.64.0", + "@tanstack/query-async-storage-persister": "^5.101.4", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query-persist-client": "^5.101.4", "date-fns": "^4.4.0", "email-validator": "^2.0.4", "expo": "~57.0.9", @@ -46,11 +49,7 @@ "react-native-screens": "~4.26.0", "react-native-svg": "15.15.4", "react-native-svg-transformer": "^1.5.3", - "react-native-worklets": "0.10.1", - "react-redux": "^9.2.0", - "redux": "^5.0.1", - "redux-persist": "^6.0.0", - "redux-thunk": "^3.1.0" + "react-native-worklets": "0.10.1" }, "devDependencies": { "@babel/core": "^7.24.9", @@ -69,7 +68,9 @@ }, "expo": { "install": { - "exclude": ["@sentry/react-native"] + "exclude": [ + "@sentry/react-native" + ] } }, "allowScripts": { diff --git a/src/App.test.tsx b/src/App.test.tsx index b3f5714..25270d8 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -2,8 +2,24 @@ import { render, waitFor } from '@testing-library/react-native'; import React from 'react'; import App from '@/App'; +import { appQueryClient } from '@/queries/queryClient'; -it('renders the whole app without crashing', async () => { - const view = render(); - await waitFor(() => expect(view.toJSON()).toBeTruthy()); +// The official stripe jest mock renders a placeholder instead of its children; +// pass them through so the app tree below it can mount +jest.mock('@stripe/stripe-react-native', () => ({ + ...jest.requireActual('@stripe/stripe-react-native/jest/mock.js'), + StripeProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +// The app-level query client is module-global, don't leak cache between tests +afterEach(() => appQueryClient.clear()); + +it('renders the whole app up to the navigation tabs', async () => { + const { findByText } = render(); + + // A tab label proves the auth bootstrap finished and Navigation mounted + expect(await findByText('Medlemskap')).toBeTruthy(); + + // Let the mounted screens' queries settle before the test ends + await waitFor(() => expect(appQueryClient.isFetching()).toBe(0)); }); diff --git a/src/App.tsx b/src/App.tsx index 138dde7..d65aaaa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,17 +1,18 @@ import { ApolloClient, HttpLink, InMemoryCache, ApolloProvider } from '@apollo/client'; import { StripeProvider } from '@stripe/stripe-react-native'; +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; import Constants from 'expo-constants'; import * as Notifications from 'expo-notifications'; import { StatusBar } from 'expo-status-bar'; import React from 'react'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { Provider } from 'react-redux'; -import { PersistGate } from 'redux-persist/integration/react'; import 'expo-dev-client'; +import { AuthProvider } from '@/auth/AuthContext'; +import { useDeepLinking } from '@/hooks'; +import { appQueryClient, persistOptions } from '@/queries/queryClient'; + import AppInner from './AppInner'; -import { useDeepLinking } from './hooks'; -import { store, persistor } from './reduxStore'; // Configure notifications Notifications.setNotificationHandler({ @@ -33,8 +34,8 @@ export default function App() { return ( - - + + {/* FIXME: Read urlScheme and merchantIdentifier from .Config */} - - + + ); } -/** YOLO */ diff --git a/src/AppInner.tsx b/src/AppInner.tsx index 5f90a87..4e53a70 100644 --- a/src/AppInner.tsx +++ b/src/AppInner.tsx @@ -1,10 +1,10 @@ import React from 'react'; import Navigation from './Navigation'; -import { useLogoutUsersWithNoToken } from './hooks'; +import { useAppStateFocusManager } from './hooks'; const AppInner = () => { - useLogoutUsersWithNoToken(); + useAppStateFocusManager(); return ; }; diff --git a/src/actions.ts b/src/actions.ts deleted file mode 100644 index d03846a..0000000 --- a/src/actions.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { UserType } from './types/data'; - -const LOGIN_REQUEST = 'LOGIN_REQUEST'; -const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; -const LOGIN_FAILURE = 'LOGIN_FAILURE'; -const LOGOUT = 'LOGOUT'; -const USER_DATA_REQUEST = 'USER_DATA_REQUEST'; -const USER_DATA_SUCCESS = 'USER_DATA_SUCCESS'; -const USER_DATA_FAILURE = 'USER_DATA_FAILURE'; -const REGISTER_USER_REQUEST = 'REGISTER_USER_REQUEST'; -const REGISTER_USER_SUCCESS = 'REGISTER_USER_SUCCESS'; -const REGISTER_USER_FAILURE = 'REGISTER_USER_FAILURE'; -const DELETE_USER_REQUEST = 'DELETE_USER_REQUEST'; -const DELETE_USER_SUCCESS = 'DELETE_USER_SUCCESS'; -const DELETE_USER_FAILURE = 'DELETE_USER_FAILURE'; - -function loginRequest() { - return { - type: LOGIN_REQUEST, - }; -} - -function loginSuccess(username: string, token: string) { - return { - type: LOGIN_SUCCESS, - username, - token, - }; -} - -function loginFailure(loginError: string) { - return { - type: LOGIN_FAILURE, - loginError, - }; -} - -function logout() { - return { - type: LOGOUT, - }; -} - -function userDataRequest() { - return { - type: USER_DATA_REQUEST, - }; -} - -function userDataSuccess(data: UserType) { - return { - type: USER_DATA_SUCCESS, - data, - }; -} - -function userDataFailure(userError: string) { - return { - type: USER_DATA_FAILURE, - userError, - }; -} - -function registerUserRequest() { - return { - type: REGISTER_USER_REQUEST, - }; -} - -function registerUserSuccess(data: UserType) { - return { - type: REGISTER_USER_SUCCESS, - data, - }; -} - -function registerUserFailure(registerError: Record) { - return { - type: REGISTER_USER_FAILURE, - registerError, - }; -} - -function deleteUserRequest() { - return { - type: DELETE_USER_REQUEST, - }; -} - -function deleteUserSuccess() { - return { - type: DELETE_USER_SUCCESS, - }; -} - -function deleteUserFailure(deleteUserError: Record) { - return { - type: DELETE_USER_FAILURE, - deleteUserError, - }; -} - -export { - /* Login */ - LOGIN_REQUEST, - loginRequest, - LOGIN_SUCCESS, - loginSuccess, - LOGIN_FAILURE, - loginFailure, - /* Logout */ - LOGOUT, - logout, - /* User data */ - USER_DATA_REQUEST, - userDataRequest, - USER_DATA_SUCCESS, - userDataSuccess, - USER_DATA_FAILURE, - userDataFailure, - /* Register */ - REGISTER_USER_REQUEST, - registerUserRequest, - REGISTER_USER_SUCCESS, - registerUserSuccess, - REGISTER_USER_FAILURE, - registerUserFailure, - /* Delete */ - DELETE_USER_REQUEST, - deleteUserRequest, - DELETE_USER_SUCCESS, - deleteUserSuccess, - DELETE_USER_FAILURE, - deleteUserFailure, -}; diff --git a/src/api.test.ts b/src/api.test.ts index 18a5ced..df426b1 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -1,49 +1,28 @@ -import { applyMiddleware, legacy_createStore as createStore } from 'redux'; -import { thunk } from 'redux-thunk'; - -import { - requestDeleteUser, - requestLogin, - requestRegisterUser, - requestStripePaymentSheet, - requestUserData, -} from '@/api'; -import rootReducer from '@/reducers'; - -const createTestStore = () => createStore(rootReducer, undefined, applyMiddleware(thunk)); +import { deleteUser, fetchEvents, fetchUserData, login, registerUser, requestStripePaymentSheet } from '@/api'; +import { ApiError } from '@/errors'; const fetchMock = global.fetch as jest.Mock; -const mockResponseOnce = (json: unknown, ok = true) => - fetchMock.mockResolvedValueOnce({ ok, status: ok ? 200 : 400, json: async () => json }); +const mockResponseOnce = (json: unknown, ok = true, status = ok ? 200 : 400) => + fetchMock.mockResolvedValueOnce({ ok, status, json: async () => json }); beforeEach(() => { fetchMock.mockClear(); }); -describe('dev fake users (__DEV__ only)', () => { - it.each([ - ['nonmember', false], - ['member', true], - ])('logs in "%s" with a fake user without hitting the network', async (username, isMember) => { - const store = createTestStore(); +const registerFormValues = { + first_name: 'Grisen', + last_name: 'Testersen', + email: 'grisen@example.test', + phone_number: '+4712345678', + password: 'hunter2', +}; - await store.dispatch(requestLogin(username as string, 'any-password')); - expect(store.getState().isAuthenticated).toBe(true); - - await store.dispatch(requestUserData(store.getState().userToken ?? '')); - expect(store.getState().user?.is_member).toBe(isMember); - - expect(global.fetch).not.toHaveBeenCalled(); - }); -}); - -describe('requestLogin', () => { - it('posts credentials and stores the token on success', async () => { - const store = createTestStore(); +describe('login', () => { + it('posts credentials and resolves with the token', async () => { mockResponseOnce({ token: 'token-123' }); - await store.dispatch(requestLogin('grisen', 'hunter2')); + await expect(login('grisen', 'hunter2')).resolves.toEqual({ token: 'token-123' }); expect(fetchMock).toHaveBeenCalledWith( 'https://dusken.example.test/auth/obtain-token/', @@ -52,48 +31,49 @@ describe('requestLogin', () => { body: JSON.stringify({ username: 'grisen', password: 'hunter2' }), }), ); - expect(store.getState()).toMatchObject({ - isAuthenticated: true, - isLoggingIn: false, - userToken: 'token-123', - loginError: null, - }); }); - it('stores the API error body on a rejected login', async () => { - jest.spyOn(console, 'log').mockImplementation(() => {}); - const store = createTestStore(); + it('throws an ApiError carrying the DRF body on a rejected login', async () => { const apiError = { non_field_errors: ['Kan ikke logge inn med disse opplysningene'] }; mockResponseOnce(apiError, false); - await store.dispatch(requestLogin('grisen', 'wrong-password')); + const error = await login('grisen', 'wrong-password').catch((e) => e); - expect(store.getState()).toMatchObject({ - isAuthenticated: false, - isLoggingIn: false, - userToken: null, - loginError: apiError, - }); + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ fields: apiError, status: 400 }); }); - it('falls back to a generic error message when the API returns an empty body', async () => { - jest.spyOn(console, 'log').mockImplementation(() => {}); - const store = createTestStore(); + it('falls back to the generic Norwegian body when the API returns an empty body', async () => { mockResponseOnce(null, false); - await store.dispatch(requestLogin('grisen', 'hunter2')); + const error = await login('grisen', 'hunter2').catch((e) => e); - expect(store.getState().loginError).toEqual({ non_field_errors: ['Kunne ikke logge inn, prøv igjen'] }); + expect(error).toBeInstanceOf(ApiError); + expect(error.fields).toEqual({ non_field_errors: ['Kunne ikke logge inn, prøv igjen'] }); + }); + + it('short-circuits dev fake users without hitting the network', async () => { + await expect(login('member', 'any-password')).resolves.toEqual({ token: 'dev-fake-token:member' }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects with the raw error, not an ApiError, on network failure', async () => { + fetchMock.mockRejectedValueOnce(new Error('network down')); + + const error = await login('grisen', 'hunter2').catch((e) => e); + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(ApiError); }); }); -describe('requestUserData', () => { - it('fetches /api/me/ with the auth token and stores the user', async () => { - const store = createTestStore(); +describe('fetchUserData', () => { + it('fetches /api/me/ with the auth token', async () => { const user = { username: 'grisen', email: 'grisen@example.test', is_member: true }; mockResponseOnce(user); - await store.dispatch(requestUserData('token-123')); + await expect(fetchUserData('token-123')).resolves.toEqual(user); expect(fetchMock).toHaveBeenCalledWith( 'https://dusken.example.test/api/me/', @@ -102,72 +82,56 @@ describe('requestUserData', () => { headers: expect.objectContaining({ Authorization: 'Token token-123' }), }), ); - expect(store.getState()).toMatchObject({ isFetchingUserData: false, user }); }); - it('stores the API error body when fetching user data fails', async () => { - const store = createTestStore(); - const apiError = { detail: 'Ugyldig token.' }; - mockResponseOnce(apiError, false); + it('resolves dev fake tokens without hitting the network', async () => { + const user = await fetchUserData('dev-fake-token:nonmember'); - await store.dispatch(requestUserData('expired-token')); + expect(user.is_member).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); - expect(store.getState()).toMatchObject({ isFetchingUserData: false, user: null, userError: apiError }); + it('throws an ApiError with the status, so a 401 is recognizable', async () => { + mockResponseOnce({ detail: 'Ugyldig token.' }, false, 401); + + const error = await fetchUserData('revoked-token').catch((e) => e); + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ status: 401, fields: { detail: 'Ugyldig token.' } }); }); }); -describe('requestRegisterUser', () => { - const formValues = { - first_name: 'Grisen', - last_name: 'Testersen', - email: 'grisen@example.test', - phone_number: '+4712345678', - password: 'hunter2', - }; - - it('authenticates with the returned auth_token and stores the user without it', async () => { - const store = createTestStore(); +describe('registerUser', () => { + it('splits the auth_token from the returned user', async () => { mockResponseOnce({ username: 'grisen', email: 'grisen@example.test', auth_token: 'token-456' }); - await store.dispatch(requestRegisterUser(formValues)); + await expect(registerUser(registerFormValues)).resolves.toEqual({ + user: { username: 'grisen', email: 'grisen@example.test' }, + token: 'token-456', + }); expect(fetchMock).toHaveBeenCalledWith( 'https://dusken.example.test/api/user/register/', - expect.objectContaining({ method: 'POST', body: JSON.stringify(formValues) }), + expect.objectContaining({ method: 'POST', body: JSON.stringify(registerFormValues) }), ); - expect(store.getState()).toMatchObject({ - isRegisteringUser: false, - isAuthenticated: true, - userToken: 'token-456', - user: { username: 'grisen', email: 'grisen@example.test' }, - }); - expect(store.getState().user).not.toHaveProperty('auth_token'); }); - it('stores field errors from the API when registration fails', async () => { - const store = createTestStore(); + it('throws an ApiError carrying field errors', async () => { const apiError = { email: ['Denne e-postadressen er allerede i bruk'] }; mockResponseOnce(apiError, false); - await store.dispatch(requestRegisterUser(formValues)); + const error = await registerUser(registerFormValues).catch((e) => e); - expect(store.getState()).toMatchObject({ - isRegisteringUser: false, - isAuthenticated: false, - registerError: apiError, - }); + expect(error).toBeInstanceOf(ApiError); + expect(error.fields).toEqual(apiError); }); }); -describe('requestDeleteUser', () => { - it('resets the store to its initial state on success', async () => { - const store = createTestStore(); - // Authenticate first (via the fake-user shortcut) so the reset is observable - await store.dispatch(requestLogin('member', 'any-password')); - expect(store.getState().isAuthenticated).toBe(true); +describe('deleteUser', () => { + it('sends DELETE with the auth token and resolves on success', async () => { + fetchMock.mockResolvedValueOnce({ ok: true, status: 204 }); - mockResponseOnce({}); - await store.dispatch(requestDeleteUser('token-123')); + await expect(deleteUser('token-123')).resolves.toBeUndefined(); expect(fetchMock).toHaveBeenCalledWith( 'https://dusken.example.test/api/me/delete/', @@ -176,17 +140,35 @@ describe('requestDeleteUser', () => { headers: expect.objectContaining({ Authorization: 'Token token-123' }), }), ); - expect(store.getState()).toMatchObject({ isAuthenticated: false, userToken: null, user: null }); }); - it('stores the API error body when deletion fails', async () => { - const store = createTestStore(); - const apiError = { detail: 'Kan ikke slette bruker med aktivt medlemskap' }; - mockResponseOnce(apiError, false); + it('throws an ApiError carrying the detail body on failure', async () => { + mockResponseOnce({ detail: 'Kan ikke slette bruker med aktivt medlemskap' }, false); - await store.dispatch(requestDeleteUser('token-123')); + const error = await deleteUser('token-123').catch((e) => e); - expect(store.getState()).toMatchObject({ isDeletingUser: false, deleteUserError: apiError }); + expect(error).toBeInstanceOf(ApiError); + expect(error.fields).toEqual({ detail: 'Kan ikke slette bruker med aktivt medlemskap' }); + }); +}); + +describe('fetchEvents', () => { + it('resolves the events list', async () => { + const events = [{ id: '1', title: 'Lørdagsquiz' }]; + mockResponseOnce({ events }); + + await expect(fetchEvents()).resolves.toEqual(events); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://events.example.test/api/v1/events?view=compact-app', + expect.anything(), + ); + }); + + it('throws when the API reports an error', async () => { + mockResponseOnce({ error: 'oops' }); + + await expect(fetchEvents()).rejects.toThrow('Kunne ikke hente programmet'); }); }); diff --git a/src/api.ts b/src/api.ts index e382da1..69a9dbe 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,21 +1,7 @@ -import { - loginFailure, - loginRequest, - loginSuccess, - registerUserFailure, - registerUserRequest, - registerUserSuccess, - userDataFailure, - userDataRequest, - userDataSuccess, - deleteUserFailure, - deleteUserSuccess, - deleteUserRequest, -} from './actions'; -import type { UserRegisterFormValues } from './modules/register/useUserRegister'; -import { AppDispatch } from './reduxStore'; -import type { UserType } from './types/data'; -import { fetchWithTimeout } from './utils'; +import { ApiError } from '@/errors'; +import type { EventType, UserType } from '@/types/data'; +import type { UserRegisterFormValues } from '@/types/forms'; +import { fetchWithTimeout } from '@/utils'; // Dev builds only (stripped from release builds via __DEV__): log in with one of // these usernames and any password to get a fake user, for previewing membership @@ -46,173 +32,99 @@ const DEV_FAKE_USERS: Record = { }, }; -export function requestLogin(username: string, password: string) { - return (dispatch: AppDispatch) => { - if (__DEV__ && username in DEV_FAKE_USERS) { - dispatch(loginSuccess(username, DEV_FAKE_TOKEN_PREFIX + username)); - return Promise.resolve(); +const JSON_HEADERS = { + Accept: 'application/json', + 'Content-Type': 'application/json', +}; + +// Throws ApiError with the DRF body on non-ok responses; network/timeout +// failures reject with the raw Error +async function requestJson(url: string, options: RequestInit, fallback: string) { + const response = await fetchWithTimeout(url, options); + const json = await response.json(); + if (!response.ok) { + throw new ApiError(json ?? { non_field_errors: [fallback] }, response.status); + } + return json; +} + +export async function login(username: string, password: string): Promise<{ token: string }> { + if (__DEV__ && username in DEV_FAKE_USERS) { + return { token: DEV_FAKE_TOKEN_PREFIX + username }; + } + return requestJson( + `${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/auth/obtain-token/`, + { method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({ username, password }) }, + 'Kunne ikke logge inn, prøv igjen', + ); +} + +export async function fetchUserData(authToken: string): Promise { + if (__DEV__ && authToken.startsWith(DEV_FAKE_TOKEN_PREFIX)) { + const fakeUser = DEV_FAKE_USERS[authToken.slice(DEV_FAKE_TOKEN_PREFIX.length)]; + if (fakeUser) { + return fakeUser; } + } + return requestJson( + `${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/api/me/`, + { method: 'GET', headers: { ...JSON_HEADERS, Authorization: `Token ${authToken}` } }, + 'Kunne ikke hente brukerdata', + ); +} - // We are now logging in - dispatch(loginRequest()); - - return fetchWithTimeout(`${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/auth/obtain-token/`, { +// snake_case so field errors from the API map straight onto form fields +export async function registerUser({ + first_name, + last_name, + email, + phone_number, + password, +}: UserRegisterFormValues): Promise<{ user: UserType; token: string }> { + const json = await requestJson( + `${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/api/user/register/`, + { method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - username, - password, - }), - }) - .then((response) => response.json().then((json) => ({ json, response }))) - .then(({ json, response }) => { - if (response.ok === false) { - return Promise.reject(json); - } - return json; - }) - .then( - (data) => { - dispatch(loginSuccess(username, data.token)); - }, - (data) => { - console.log(data); - dispatch(loginFailure(data || { non_field_errors: ['Kunne ikke logge inn, prøv igjen'] })); - }, - ); - }; + headers: JSON_HEADERS, + body: JSON.stringify({ first_name, last_name, email, phone_number, password }), + }, + 'Kunne ikke registrere bruker, prøv igjen', + ); + // The response is the user with its auth_token riding along; keep them apart + const { auth_token: token, ...user } = json; + return { user, token }; } -export function requestUserData(authToken: string) { - return (dispatch: AppDispatch) => { - if (__DEV__ && authToken.startsWith(DEV_FAKE_TOKEN_PREFIX)) { - const fakeUser = DEV_FAKE_USERS[authToken.slice(DEV_FAKE_TOKEN_PREFIX.length)]; - if (fakeUser) { - dispatch(userDataSuccess(fakeUser)); - return Promise.resolve(); - } - } - - // We are now fetching user data - dispatch(userDataRequest()); - - return fetchWithTimeout(`${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/api/me/`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: `Token ${authToken}`, - }, - }) - .then((response) => response.json().then((json) => ({ json, response }))) - .then(({ json, response }) => { - if (response.ok === false) { - return Promise.reject(json); - } - return json; - }) - .then( - (data) => { - dispatch(userDataSuccess(data)); - }, - (data) => { - dispatch(userDataFailure(data || { non_field_errors: ['Kunne ikke hente brukerdata'] })); - }, - ); - }; -} - -// We use snake_case in order to fetch and display field errors from the API without any additional formatting -export function requestRegisterUser({ first_name, last_name, email, phone_number, password }: UserRegisterFormValues) { - return (dispatch: AppDispatch) => { - dispatch(registerUserRequest()); - - return fetchWithTimeout(`${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/api/user/register/`, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - first_name, - last_name, - email, - phone_number, - password, - }), - }) - .then((response) => response.json().then((json) => ({ json, response }))) - .then(({ json, response }) => { - if (response.ok === false) { - return Promise.reject(json); - } - return json; - }) - .then( - (data) => { - dispatch(registerUserSuccess(data)); - }, - (data) => { - dispatch(registerUserFailure(data || { non_field_errors: ['Kunne ikke registrere bruker, prøv igjen'] })); - }, - ); - }; -} - -export function requestDeleteUser(authToken: string) { - return (dispatch: AppDispatch) => { - dispatch(deleteUserRequest()); - - return fetchWithTimeout(`${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/api/me/delete/`, { - method: 'DELETE', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: `Token ${authToken}`, - }, - }).then((response) => { - if (!response.ok) { - return response.json().then((json) => { - dispatch(deleteUserFailure(json || { non_field_errors: ['Kunne ikke slette bruker, prøv igjen'] })); - }); - } - dispatch(deleteUserSuccess()); - return {}; - }); - }; -} - -/** Create a stripe payment sheet. Note that this is not using redux */ -export async function requestStripePaymentSheet(authToken: string, membershipType: string) { - const body = JSON.stringify({ - membership_type: membershipType, +export async function deleteUser(authToken: string): Promise { + const response = await fetchWithTimeout(`${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/api/me/delete/`, { + method: 'DELETE', + headers: { ...JSON_HEADERS, Authorization: `Token ${authToken}` }, }); - const headers = { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: `Token ${authToken}`, - }; - return fetchWithTimeout(`${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/api/stripe/payment-sheet/`, { + if (!response.ok) { + const json = await response.json(); + throw new ApiError(json ?? { non_field_errors: ['Kunne ikke slette bruker, prøv igjen'] }, response.status); + } +} + +export async function fetchEvents(): Promise { + const response = await fetchWithTimeout(`${process.env.EXPO_PUBLIC_EVENT_API_URL}?view=compact-app`); + const { events, error } = await response.json(); + if (error || !events) { + throw new Error('Kunne ikke hente programmet'); + } + return events; +} + +/** Create a stripe payment sheet; rejects with a message for the failure toast. */ +export async function requestStripePaymentSheet(authToken: string, membershipType: string) { + const response = await fetchWithTimeout(`${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/api/stripe/payment-sheet/`, { method: 'POST', - headers, - body, - }) - .then((response) => response.json().then((json) => ({ json, response }))) - .then(({ json, response }) => { - if (response.ok === false) { - return Promise.reject(json); - } - return json; - }) - .then( - (data) => { - return data; - }, - (data) => { - throw new Error(data?.detail || 'Kunne ikke belaste bankkortet, prøv igjen'); - }, - ); + headers: { ...JSON_HEADERS, Authorization: `Token ${authToken}` }, + body: JSON.stringify({ membership_type: membershipType }), + }); + const json = await response.json(); + if (!response.ok) { + throw new Error(json?.detail || 'Kunne ikke belaste bankkortet, prøv igjen'); + } + return json; } diff --git a/src/auth/AuthContext.test.tsx b/src/auth/AuthContext.test.tsx new file mode 100644 index 0000000..f2c62fb --- /dev/null +++ b/src/auth/AuthContext.test.tsx @@ -0,0 +1,99 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { AuthProvider, useAuth } from '@/auth/AuthContext'; +import { queryKeys } from '@/queries/queryKeys'; + +const TOKEN_KEY = 'cnapp.authToken'; + +const grisen = { username: 'grisen', email: 'grisen@example.test', is_member: true }; + +// gcTime: Infinity so no GC timers keep Jest alive +const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { gcTime: Infinity } } }); + +const renderAuth = ({ + initialToken, + queryClient = createQueryClient(), +}: { initialToken?: string | null; queryClient?: QueryClient } = {}) => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + return { queryClient, ...renderHook(() => useAuth(), { wrapper }) }; +}; + +beforeEach(async () => { + // The AsyncStorage mock's methods are shared jest.fn()s; clear their history + jest.clearAllMocks(); + await AsyncStorage.clear(); +}); + +it('renders nothing until the stored session is restored', async () => { + await AsyncStorage.setItem(TOKEN_KEY, 'token-123'); + + const { result } = renderAuth(); + // The gate: children (and thus the hook) have not rendered yet + expect(result.current).toBeNull(); + + await waitFor(() => expect(result.current).not.toBeNull()); + expect(result.current.token).toBe('token-123'); + expect(result.current.isAuthenticated).toBe(true); +}); + +it('starts logged out when nothing is stored', async () => { + const { result } = renderAuth(); + + await waitFor(() => expect(result.current).not.toBeNull()); + expect(result.current.token).toBeNull(); + expect(result.current.isAuthenticated).toBe(false); +}); + +it('runs the redux-persist migration and seeds the cached user', async () => { + await AsyncStorage.setItem( + 'persist:root', + JSON.stringify({ userToken: JSON.stringify('9a8b'), user: JSON.stringify(grisen) }), + ); + + const { result, queryClient } = renderAuth(); + + await waitFor(() => expect(result.current?.token).toBe('9a8b')); + expect(queryClient.getQueryData(queryKeys.user)).toEqual(grisen); + expect(await AsyncStorage.getItem('persist:root')).toBeNull(); +}); + +it('signIn stores the token and flips isAuthenticated', async () => { + const { result } = renderAuth({ initialToken: null }); + + act(() => result.current.signIn('token-1')); + + expect(result.current.token).toBe('token-1'); + expect(result.current.isAuthenticated).toBe(true); + await waitFor(async () => expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('token-1')); +}); + +it('signOut clears the token and drops the cached user', async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(queryKeys.user, grisen); + await AsyncStorage.setItem(TOKEN_KEY, 'token-1'); + + const { result } = renderAuth({ initialToken: 'token-1', queryClient }); + act(() => result.current.signOut()); + + expect(result.current.token).toBeNull(); + expect(result.current.isAuthenticated).toBe(false); + expect(queryClient.getQueryData(queryKeys.user)).toBeUndefined(); + await waitFor(async () => expect(await AsyncStorage.getItem(TOKEN_KEY)).toBeNull()); +}); + +it('skips the storage bootstrap when given an initialToken', () => { + const getItemSpy = jest.spyOn(AsyncStorage, 'getItem'); + + const { result } = renderAuth({ initialToken: 'token-x' }); + + // Synchronously ready: no gate, no storage reads + expect(result.current.token).toBe('token-x'); + expect(getItemSpy).not.toHaveBeenCalled(); +}); diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx new file mode 100644 index 0000000..4a0a4df --- /dev/null +++ b/src/auth/AuthContext.tsx @@ -0,0 +1,87 @@ +import { useQueryClient } from '@tanstack/react-query'; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; + +import { migrateReduxPersistAuth } from '@/auth/migrateFromReduxPersist'; +import { getStoredToken, setStoredToken } from '@/auth/tokenStorage'; +import { queryKeys } from '@/queries/queryKeys'; + +interface AuthContextValue { + token: string | null; + isAuthenticated: boolean; + signIn: (token: string) => void; + signOut: () => void; +} + +const AuthContext = createContext(null); + +interface AuthProviderProps { + children: React.ReactNode; + /** Test-only: start with this token and skip the storage bootstrap. */ + initialToken?: string | null; +} + +/** + * Owns the auth token. Renders nothing until the stored session is known. + * Must sit under the query provider. + */ +export function AuthProvider({ children, initialToken }: AuthProviderProps) { + const skipBootstrap = initialToken !== undefined; + const [token, setToken] = useState(initialToken ?? null); + const [ready, setReady] = useState(skipBootstrap); + const queryClient = useQueryClient(); + + useEffect(() => { + if (skipBootstrap) { + return; + } + let cancelled = false; + (async () => { + const migrated = await migrateReduxPersistAuth(); + if (migrated.user) { + queryClient.setQueryData(queryKeys.user, migrated.user); + } + let storedToken: string | null = null; + try { + storedToken = await getStoredToken(); + } catch { + // Unreadable storage means logged out for this launch + } + if (!cancelled) { + setToken(storedToken); + setReady(true); + } + })(); + return () => { + cancelled = true; + }; + }, [skipBootstrap, queryClient]); + + const signIn = useCallback((newToken: string) => { + setToken(newToken); + setStoredToken(newToken).catch(() => {}); + }, []); + + const signOut = useCallback(() => { + setToken(null); + setStoredToken(null).catch(() => {}); + // So an in-flight /api/me/ can't resurrect the user after logout + queryClient.cancelQueries({ queryKey: queryKeys.user }).catch(() => {}); + queryClient.removeQueries({ queryKey: queryKeys.user }); + }, [queryClient]); + + const value = useMemo(() => ({ token, isAuthenticated: token !== null, signIn, signOut }), [token, signIn, signOut]); + + if (!ready) { + return null; + } + + return {children}; +} + +export function useAuth(): AuthContextValue { + const value = useContext(AuthContext); + if (!value) { + throw new Error('useAuth must be used inside an AuthProvider'); + } + return value; +} diff --git a/src/auth/migrateFromReduxPersist.test.ts b/src/auth/migrateFromReduxPersist.test.ts new file mode 100644 index 0000000..1e947c4 --- /dev/null +++ b/src/auth/migrateFromReduxPersist.test.ts @@ -0,0 +1,135 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { migrateReduxPersistAuth } from '@/auth/migrateFromReduxPersist'; + +const TOKEN_KEY = 'cnapp.authToken'; +const PERSIST_KEY = 'persist:root'; + +const grisen = { username: 'grisen', email: 'grisen@example.test', is_member: true }; + +// redux-persist double-encodes: each value in the blob is its own JSON document +const persistBlob = (fields: Record) => + JSON.stringify(Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, JSON.stringify(value)]))); + +const seedBlob = (fields: Record) => + AsyncStorage.setItem(PERSIST_KEY, persistBlob({ _persist: { version: -1, rehydrated: true }, ...fields })); + +beforeEach(async () => { + // The AsyncStorage mock's methods are shared jest.fn()s; clear their history + jest.clearAllMocks(); + await AsyncStorage.clear(); +}); + +it('reports logged out when no redux-persist blob exists', async () => { + await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null }); + + expect(await AsyncStorage.getItem(TOKEN_KEY)).toBeNull(); +}); + +it('moves the token to the new key and returns the cached user', async () => { + await seedBlob({ userToken: '9a8b', user: grisen }); + + await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: '9a8b', user: grisen }); + + expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('9a8b'); + expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull(); +}); + +it('treats a malformed outer blob as logged out and removes it', async () => { + await AsyncStorage.setItem(PERSIST_KEY, 'not-json{'); + + await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null }); + + expect(await AsyncStorage.getItem(TOKEN_KEY)).toBeNull(); + expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull(); +}); + +it('treats a blob without userToken as logged out and removes it', async () => { + await seedBlob({ user: grisen }); + + await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null }); + + expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull(); +}); + +it('treats a logged out blob (userToken null) as logged out and removes it', async () => { + await seedBlob({ userToken: null }); + + await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null }); + + expect(await AsyncStorage.getItem(TOKEN_KEY)).toBeNull(); + expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull(); +}); + +it('treats a non-string token as logged out and removes the blob', async () => { + await seedBlob({ userToken: 42 }); + + await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null }); + + expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull(); +}); + +it('treats an unparseable inner token as logged out and removes the blob', async () => { + await AsyncStorage.setItem(PERSIST_KEY, JSON.stringify({ userToken: 'not{json' })); + + await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null }); + + expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull(); +}); + +it('returns logged out and touches nothing when storage is unreadable', async () => { + await seedBlob({ userToken: '9a8b' }); + jest.spyOn(AsyncStorage, 'getItem').mockRejectedValueOnce(new Error('storage unreadable')); + const removeSpy = jest.spyOn(AsyncStorage, 'removeItem'); + + await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null }); + + expect(removeSpy).not.toHaveBeenCalled(); + expect(await AsyncStorage.getItem(PERSIST_KEY)).not.toBeNull(); +}); + +it('keeps an already-migrated token, without seeding, and still removes the blob', async () => { + await AsyncStorage.setItem(TOKEN_KEY, 'existing-token'); + await seedBlob({ userToken: 'old-token', user: grisen }); + + await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: 'existing-token', user: null }); + + expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('existing-token'); + expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull(); +}); + +it('migrates dev fake tokens verbatim', async () => { + await seedBlob({ userToken: 'dev-fake-token:member' }); + + const result = await migrateReduxPersistAuth(); + + expect(result.token).toBe('dev-fake-token:member'); + expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('dev-fake-token:member'); +}); + +it('keeps the token when the user blob is unparseable', async () => { + await AsyncStorage.setItem(PERSIST_KEY, JSON.stringify({ userToken: JSON.stringify('9a8b'), user: 'not{json' })); + + await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: '9a8b', user: null }); + + expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('9a8b'); + expect(await AsyncStorage.getItem(PERSIST_KEY)).toBeNull(); +}); + +it('keeps the blob for a retry when the token cannot be stored', async () => { + await seedBlob({ userToken: '9a8b' }); + jest.spyOn(AsyncStorage, 'setItem').mockRejectedValueOnce(new Error('disk full')); + + await expect(migrateReduxPersistAuth()).resolves.toEqual({ token: null, user: null }); + + expect(await AsyncStorage.getItem(PERSIST_KEY)).not.toBeNull(); +}); + +it('never throws when removing the blob fails', async () => { + await seedBlob({ userToken: '9a8b' }); + jest.spyOn(AsyncStorage, 'removeItem').mockRejectedValueOnce(new Error('storage error')); + + await expect(migrateReduxPersistAuth()).resolves.toMatchObject({ token: '9a8b' }); + + expect(await AsyncStorage.getItem(TOKEN_KEY)).toBe('9a8b'); +}); diff --git a/src/auth/migrateFromReduxPersist.ts b/src/auth/migrateFromReduxPersist.ts new file mode 100644 index 0000000..97fdabd --- /dev/null +++ b/src/auth/migrateFromReduxPersist.ts @@ -0,0 +1,76 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { getStoredToken, setStoredToken } from '@/auth/tokenStorage'; +import type { UserType } from '@/types/data'; + +const REDUX_PERSIST_KEY = 'persist:root'; + +interface MigratedAuth { + token: string | null; + user: UserType | null; +} + +const LOGGED_OUT: MigratedAuth = { token: null, user: null }; + +/** + * One-shot move of the session out of the redux-persist blob into + * tokenStorage. + * The blob is only removed once the token is safely stored or provably + * absent, so failures retry next launch. + */ +export async function migrateReduxPersistAuth(): Promise { + let blob: string | null; + try { + blob = await AsyncStorage.getItem(REDUX_PERSIST_KEY); + } catch { + return LOGGED_OUT; + } + if (blob === null) { + return LOGGED_OUT; + } + + // The blob is a JSON object whose values are each their own JSON document + let token: string | null = null; + let user: UserType | null = null; + try { + const root = JSON.parse(blob); + const innerToken = JSON.parse(root.userToken); + if (typeof innerToken === 'string') { + token = innerToken; + try { + const innerUser = JSON.parse(root.user); + if (innerUser && typeof innerUser === 'object') { + user = innerUser; + } + } catch { + // The token still migrates; a refetch covers the user + } + } + } catch { + // Unparseable blob or no token → logged out + } + + if (token !== null) { + try { + const existingToken = await getStoredToken(); + if (existingToken !== null) { + // An earlier run already migrated; don't overwrite, don't seed + token = existingToken; + user = null; + } else { + await setStoredToken(token); + } + } catch { + // The token never reached its new home; keep the blob for a retry + return LOGGED_OUT; + } + } + + try { + await AsyncStorage.removeItem(REDUX_PERSIST_KEY); + } catch { + // Re-runs next launch + } + + return { token, user }; +} diff --git a/src/auth/tokenStorage.test.ts b/src/auth/tokenStorage.test.ts new file mode 100644 index 0000000..0dfde55 --- /dev/null +++ b/src/auth/tokenStorage.test.ts @@ -0,0 +1,26 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { getStoredToken, setStoredToken } from '@/auth/tokenStorage'; + +beforeEach(async () => { + await AsyncStorage.clear(); +}); + +it('returns null when no token is stored', async () => { + expect(await getStoredToken()).toBeNull(); +}); + +it('round-trips a token through AsyncStorage', async () => { + await setStoredToken('token-123'); + + expect(await getStoredToken()).toBe('token-123'); + expect(await AsyncStorage.getItem('cnapp.authToken')).toBe('token-123'); +}); + +it('removes the token when set to null', async () => { + await setStoredToken('token-123'); + await setStoredToken(null); + + expect(await getStoredToken()).toBeNull(); + expect(await AsyncStorage.getItem('cnapp.authToken')).toBeNull(); +}); diff --git a/src/auth/tokenStorage.ts b/src/auth/tokenStorage.ts new file mode 100644 index 0000000..f7c7f63 --- /dev/null +++ b/src/auth/tokenStorage.ts @@ -0,0 +1,15 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const TOKEN_KEY = 'cnapp.authToken'; + +export async function getStoredToken(): Promise { + return AsyncStorage.getItem(TOKEN_KEY); +} + +export async function setStoredToken(token: string | null): Promise { + if (token === null) { + await AsyncStorage.removeItem(TOKEN_KEY); + } else { + await AsyncStorage.setItem(TOKEN_KEY, token); + } +} diff --git a/src/components/FormErrors.tsx b/src/components/FormErrors.tsx index e46550f..aac6e2b 100644 --- a/src/components/FormErrors.tsx +++ b/src/components/FormErrors.tsx @@ -1,23 +1,27 @@ import React from 'react'; import { Text, StyleSheet, View } from 'react-native'; +import { ApiError } from '@/errors'; import theme from '@/theme'; import IconExclamationTriangle from '@assets/icons/exclamation-triangle.svg'; -const FormErrors = ({ errors }: { errors: Record }) => { +const FormErrors = ({ errors }: { errors: Record | Error }) => { if (!errors) { return []; } + // ApiError carries the DRF body; unwrap before the instanceof Error check + const fields: Record = errors instanceof ApiError ? errors.fields : (errors as Record); + let errorFormatted; - if (errors instanceof Error) { + if (fields instanceof Error) { // This check might backfire on us as it's too general. We do not wish to display the error message to the user // Hence we display this generic one errorFormatted = 'Kunne ikke sende inn skjemaet! Prøv igjen senere...'; - } else if ('non_field_errors' in errors) { - errorFormatted = errors.non_field_errors?.[0]; - } else if ('detail' in errors) { - errorFormatted = errors.detail; + } else if ('non_field_errors' in fields) { + errorFormatted = fields.non_field_errors?.[0]; + } else if ('detail' in fields) { + errorFormatted = fields.detail; } if (!errorFormatted) { diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..d85f103 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,15 @@ +/** + * A non-ok API response, carrying the DRF error body for FormErrors to render. + * Network/timeout failures stay raw Errors and render as a generic message. + */ +export class ApiError extends Error { + fields: Record; + status: number; + + constructor(fields: Record, status: number) { + super(`API request failed with status ${status}`); + this.name = 'ApiError'; + this.fields = fields; + this.status = status; + } +} diff --git a/src/hooks.tsx b/src/hooks.tsx index af38a1f..92a228b 100644 --- a/src/hooks.tsx +++ b/src/hooks.tsx @@ -1,24 +1,16 @@ import { useStripe } from '@stripe/stripe-react-native'; -import { useCallback, useEffect, useState } from 'react'; -import { Linking } from 'react-native'; -import type { TypedUseSelectorHook } from 'react-redux'; -import { useDispatch, useSelector, useStore } from 'react-redux'; +import { focusManager } from '@tanstack/react-query'; +import { useCallback, useEffect } from 'react'; +import { AppState, Linking } from 'react-native'; -import { logout } from './actions'; -import type { AppDispatch, AppStore, RootState } from './reduxStore'; - -export function usePrevious(value: T): T | undefined { - const [previous, setPrevious] = useState(undefined); - const [current, setCurrent] = useState(value); - - // Adjust state during render instead of tracking the previous render's value in a ref, - // see https://react.dev/reference/react/useState#storing-information-from-previous-renders - if (value !== current) { - setPrevious(current); - setCurrent(value); - } - - return previous; +/** Drives TanStack Query's refetch-on-focus from the app's foreground state. */ +export function useAppStateFocusManager() { + useEffect(() => { + const subscription = AppState.addEventListener('change', (status) => { + focusManager.setFocused(status === 'active'); + }); + return () => subscription.remove(); + }, []); } /** Handles deep links. Currently only used by stripe */ @@ -53,21 +45,3 @@ export const useDeepLinking = () => { return () => deepLinkListener.remove(); }, [handleDeepLink]); }; - -// Use throughout your app instead of plain `useDispatch` and `useSelector` -export const useAppDispatch: () => AppDispatch = useDispatch; -export const useAppSelector: TypedUseSelectorHook = useSelector; -export const useAppStore: () => AppStore = useStore; - -/** Logout authenticated users without token */ -export function useLogoutUsersWithNoToken() { - const user = useAppSelector((store) => store.user); - const userToken = useAppSelector((store) => store.userToken); - const dispatch = useAppDispatch(); - - useEffect(() => { - if (user && !userToken) { - dispatch(logout()); - } - }); -} diff --git a/src/modules/events/EventList.test.tsx b/src/modules/events/EventList.test.tsx index 7a42811..1a6289c 100644 --- a/src/modules/events/EventList.test.tsx +++ b/src/modules/events/EventList.test.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, renderHook, waitFor } from '@testing-library/react-native'; import React from 'react'; @@ -13,6 +14,17 @@ jest.mock('@/utils', () => ({ const mockFetch = fetchWithTimeout as jest.Mock; +// FlatList spreads cell rendering over 50 ms batch timers; with real timers +// they fire outside act() at random, with fake timers waitFor advances them +// act-wrapped and deterministically +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + const event = { id: '1', title: 'Lørdagsquiz', @@ -88,10 +100,13 @@ it('renders an error state when the API is unreachable', async () => { expect(await findByText('Kunne ikke hente programmet...')).toBeTruthy(); }); -// stuck spinner bug, fix later -it.failing('stops the refresh spinner and shows the error when refresh fails', async () => { +it('stops the refresh spinner and shows the error when refresh fails', async () => { mockFetch.mockResolvedValueOnce({ json: async () => ({ events: [event] }) }); - const { result } = renderHook(() => useEventList()); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useEventList(), { wrapper }); await waitFor(() => expect(result.current.events).toHaveLength(1)); mockFetch.mockRejectedValue(new Error('network down')); diff --git a/src/modules/events/useEventList.tsx b/src/modules/events/useEventList.tsx index 2d44d3b..3338376 100644 --- a/src/modules/events/useEventList.tsx +++ b/src/modules/events/useEventList.tsx @@ -1,50 +1,16 @@ -import { useEffect, useState } from 'react'; - -import type { EventType } from '@/types/data'; -import { fetchWithTimeout } from '@/utils'; +import { useEvents } from '@/queries/useEvents'; const useEventList = () => { - const [loading, setLoading] = useState(false); - const [events, setEvents] = useState([]); - const [error, setError] = useState(null); - const [refreshing, setRefreshing] = useState(false); - - const handleRefresh = () => { - setRefreshing(true); - fetchEvents(); - }; - - const fetchEvents = async () => { - const url = `${process.env.EXPO_PUBLIC_EVENT_API_URL}?view=compact-app`; - setLoading(true); - - let response; - let fetchedEvents; - try { - const res = await fetchWithTimeout(url); - response = await res.json(); - fetchedEvents = response.events; - } catch (err) { - setError(err); - setLoading(false); - return; - } - setEvents(fetchedEvents); - setError(response.error || null); - setLoading(false); - setRefreshing(false); - }; - - useEffect(() => { - fetchEvents(); - }, []); + const { data, error, isPending, isRefetching, refetch } = useEvents(); return { - handleRefresh, + handleRefresh: () => { + refetch(); + }, error, - events, - refreshing, - loading, + events: data ?? [], + refreshing: isRefetching, + loading: isPending, }; }; diff --git a/src/modules/login/useLogin.tsx b/src/modules/login/useLogin.tsx index 2ac01eb..9f86611 100644 --- a/src/modules/login/useLogin.tsx +++ b/src/modules/login/useLogin.tsx @@ -1,9 +1,7 @@ import { useNavigation } from '@react-navigation/native'; -import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; -import { requestLogin } from '@/api'; -import { useAppDispatch, useAppSelector } from '@/hooks'; +import { useLoginMutation } from '@/queries/useLoginMutation'; interface LoginFormValues { email: string; @@ -12,12 +10,7 @@ interface LoginFormValues { const useLogin = () => { const navigation = useNavigation(); - - const dispatch = useAppDispatch(); - - const isAuthenticated = useAppSelector((store) => store.isAuthenticated); - const isLoggingIn = useAppSelector((store) => store.isLoggingIn); - const loginError = useAppSelector((store) => store.loginError); + const { mutate: login, isPending, error } = useLoginMutation(); const { control, @@ -27,17 +20,11 @@ const useLogin = () => { } = useForm(); const onSubmit = handleSubmit(({ email, password }) => { - dispatch(requestLogin(email, password)); + // goBack works since Login is always pushed from Membership + login({ username: email, password }, { onSuccess: () => navigation.goBack() }); }); - useEffect(() => { - if (isAuthenticated) { - // navigation.goBack onLogin works since LoginScreen is allways navigated to from MembershipScreen - navigation.goBack(); - } - }, [isAuthenticated, navigation]); - - return { onSubmit, isLoggingIn, isSubmitted, setFocus, control, errors: { ...errors }, loginError }; + return { onSubmit, isLoggingIn: isPending, isSubmitted, setFocus, control, errors: { ...errors }, loginError: error }; }; export default useLogin; diff --git a/src/modules/membership/Membership.test.tsx b/src/modules/membership/Membership.test.tsx index 795c105..ceec0ff 100644 --- a/src/modules/membership/Membership.test.tsx +++ b/src/modules/membership/Membership.test.tsx @@ -7,6 +7,12 @@ import Membership from '@/modules/membership/Membership'; import { renderWithProviders, testUser } from '@/testUtils'; import type { UserType } from '@/types/data'; +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ navigate: mockNavigate }), +})); + // Must match PROOF_QUERY in useProof.tsx const PROOF_QUERY = gql` { @@ -36,14 +42,6 @@ it('shows the membership pitch when logged out', async () => { it('lets a logged in non-member pick a membership type and shows its price', async () => { const nonMember: UserType = { ...testUser, is_member: false, last_membership: null }; - // Serve the non-member from the fake API so the refetch on mount does not - // clobber the preloaded state. - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - status: 200, - json: async () => nonMember, - }); - const { findByText, getByText } = renderWithProviders(, { initialToken: 'test-token', initialUser: nonMember, @@ -81,14 +79,6 @@ it('lets a logged in non-member pick a membership type and shows its price', asy }); it('shows the membership proof when logged in', async () => { - // Mounting Membership with a token triggers requestUserData; serve the - // logged in user from the fake API so the refetch does not clobber state. - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - status: 200, - json: async () => testUser, - }); - const { findByText } = renderWithProviders(, { initialToken: 'test-token', initialUser: testUser, @@ -126,9 +116,6 @@ it('refetches and renders updated user data on pull-to-refresh', async () => { const fetchMock = global.fetch as jest.Mock; const nonMember: UserType = { ...testUser, is_member: false, last_membership: null }; - // the refetch on mount still sees the non-member - fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => nonMember }); - const { findByText, UNSAFE_getByType } = renderWithProviders(, { initialToken: 'test-token', initialUser: nonMember, @@ -136,7 +123,7 @@ it('refetches and renders updated user data on pull-to-refresh', async () => { }); expect(await findByText('Velg medlemskapstype')).toBeTruthy(); - // but by the next pull-to-refresh the membership has been bought elsewhere + // the server now reports an active membership, bought elsewhere fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => testUser }); fireEvent(UNSAFE_getByType(RefreshControl), 'refresh'); @@ -149,3 +136,31 @@ it('refetches and renders updated user data on pull-to-refresh', async () => { }), ); }); + +it('signs out when the stored token has been revoked', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ detail: 'Ugyldig token.' }), + }); + + const { findByText } = renderWithProviders(, { + initialToken: 'revoked-token', + apolloMocks, + }); + + expect(await findByText('Bli medlem')).toBeTruthy(); +}); + +it('logs out and shows the logged-out membership screen again', async () => { + const { findByText } = renderWithProviders(, { + initialToken: 'test-token', + initialUser: testUser, + apolloMocks, + }); + + fireEvent.press(await findByText('Logg ut')); + + expect(await findByText('Bli medlem')).toBeTruthy(); + expect(mockNavigate).toHaveBeenCalledWith('UserStack', { screen: 'Membership' }); +}); diff --git a/src/modules/membership/Membership.tsx b/src/modules/membership/Membership.tsx index 18c5109..38f953f 100644 --- a/src/modules/membership/Membership.tsx +++ b/src/modules/membership/Membership.tsx @@ -10,7 +10,8 @@ import useMembership from './useMembership'; const Membership = () => { const { - isFetchingUserData, + isPending, + isRefetching, onRegisterPress, onLoginPress, onLogoutPress, @@ -19,21 +20,22 @@ const Membership = () => { fetchUser, } = useMembership(); - if (isFetchingUserData) { - return ( - - - - ); - } - if (isAuthenticated) { + // Only the very first fetch blocks, refreshes keep the proof mounted + if (isPending) { + return ( + + + + ); + } + return ( <> diff --git a/src/modules/membership/Proof.test.tsx b/src/modules/membership/Proof.test.tsx index dd8412d..bf42f0a 100644 --- a/src/modules/membership/Proof.test.tsx +++ b/src/modules/membership/Proof.test.tsx @@ -63,7 +63,7 @@ const renderProof = ({ mocks = apolloMocks, }: { user?: UserType; fetchUser?: () => void; mocks?: readonly MockedResponse[] } = {}) => renderWithProviders( - , + , { initialToken: 'test-token', initialUser: user, @@ -133,10 +133,10 @@ it('purchases the preselected membership through the stripe payment sheet', asyn it('purchases the selected membership type, not the default', async () => { mockPaymentSheetResponse(); - const { findByText, getByText } = renderProof(); + const { findByText } = renderProof(); fireEvent.press(await findByText('Student')); - fireEvent.press(getByText('Kjøp medlemskap (150 NOK)')); + fireEvent.press(await findByText('Kjøp medlemskap (150 NOK)')); expect(await findByText('Your order is confirmed!')).toBeTruthy(); expect(fetchMock).toHaveBeenCalledWith( diff --git a/src/modules/membership/Proof.tsx b/src/modules/membership/Proof.tsx index a309531..68fd4e8 100644 --- a/src/modules/membership/Proof.tsx +++ b/src/modules/membership/Proof.tsx @@ -254,10 +254,10 @@ const NotificationToggle = ({ user }: { user: UserType }) => { interface ProofProps { onLogoutPress: () => void; fetchUser: () => void; - isFetchingUserData: boolean; + isRefetching: boolean; onDeletePress: () => void; } -const Proof = ({ onLogoutPress, fetchUser, isFetchingUserData, onDeletePress }: ProofProps) => { +const Proof = ({ onLogoutPress, fetchUser, isRefetching, onDeletePress }: ProofProps) => { const confettiRef = useRef(null); const burstRef = useRef(null); const confettiContainerRef = useRef>(null); @@ -317,7 +317,7 @@ const Proof = ({ onLogoutPress, fetchUser, isFetchingUserData, onDeletePress }: onRainEnd={onRainEnd} /> - }> + }> diff --git a/src/modules/membership/UserDelete.tsx b/src/modules/membership/UserDelete.tsx index d23020d..383f1cc 100644 --- a/src/modules/membership/UserDelete.tsx +++ b/src/modules/membership/UserDelete.tsx @@ -1,18 +1,29 @@ -import React from 'react'; +import { useNavigation } from '@react-navigation/native'; +import React, { useEffect } from 'react'; import { ActivityIndicator, ScrollView, Text, TextInput, View, StyleSheet } from 'react-native'; +import { useAuth } from '@/auth/AuthContext'; import Button from '@/components/Button'; import FormErrors from '@/components/FormErrors'; -import { useAppSelector } from '@/hooks'; +import { useUser } from '@/queries/useUser'; import theme from '@/theme'; import useUserDelete from './useUserDelete'; const UserDelete = () => { const [value, onChangeText] = React.useState(''); - const user = useAppSelector((state) => state.user); + const navigation = useNavigation(); + const { isAuthenticated } = useAuth(); + const { user } = useUser(); const { onPress, isDeletingUser, deleteUserError } = useUserDelete(); + // Not a screen for logged-out users; this also leaves after a successful deletion + useEffect(() => { + if (!isAuthenticated) { + navigation.navigate('UserStack', { screen: 'Membership' }); + } + }, [isAuthenticated, navigation]); + if (!user) { return null; } diff --git a/src/modules/membership/useMembership.tsx b/src/modules/membership/useMembership.tsx index d338142..697be40 100644 --- a/src/modules/membership/useMembership.tsx +++ b/src/modules/membership/useMembership.tsx @@ -1,44 +1,30 @@ import { useNavigation } from '@react-navigation/native'; -import { useEffect, useCallback } from 'react'; -import { logout } from '@/actions'; -import { requestUserData } from '@/api'; -import { useAppDispatch, useAppSelector, usePrevious } from '@/hooks'; +import { useAuth } from '@/auth/AuthContext'; +import { useUser } from '@/queries/useUser'; const useMembership = () => { const navigation = useNavigation(); - const dispatch = useAppDispatch(); - const isAuthenticated = useAppSelector((store) => store.isAuthenticated); - const isFetchingUserData = useAppSelector((store) => store.isFetchingUserData); - const userToken = useAppSelector((store) => store.userToken); - const prevUserToken = usePrevious(userToken); + const { isAuthenticated, signOut } = useAuth(); + const { isPending, isRefetching, refetch } = useUser(); const onLogoutPress = () => { - dispatch(logout()); + signOut(); navigation.navigate('UserStack', { screen: 'Membership' }); }; const onRegisterPress = () => navigation.navigate('UserStack', { screen: 'UserRegister' }); const onLoginPress = () => navigation.navigate('UserStack', { screen: 'Login' }); const onDeletePress = () => navigation.navigate('UserStack', { screen: 'UserDelete' }); - const fetchUser = useCallback(() => { - dispatch(requestUserData(userToken || '')); - }, [dispatch, userToken]); - - useEffect(() => { - if (userToken !== prevUserToken && userToken) { - fetchUser(); - } - }, [userToken, prevUserToken, fetchUser]); return { isAuthenticated, - isFetchingUserData, - userToken, + isPending, + isRefetching, onLogoutPress, onRegisterPress, onDeletePress, onLoginPress, - fetchUser, + fetchUser: refetch, }; }; export default useMembership; diff --git a/src/modules/membership/useProof.tsx b/src/modules/membership/useProof.tsx index 2a0f4f5..5a5990c 100644 --- a/src/modules/membership/useProof.tsx +++ b/src/modules/membership/useProof.tsx @@ -3,9 +3,9 @@ import { useStripe } from '@stripe/stripe-react-native'; import Constants from 'expo-constants'; import { useEffect, useState } from 'react'; -import { requestStripePaymentSheet } from '@/api'; -import { useAppSelector } from '@/hooks'; import { useNotifications } from '@/hooks/useNotifications'; +import { usePaymentSheetMutation } from '@/queries/usePaymentSheetMutation'; +import { useUser } from '@/queries/useUser'; import { ToastProps } from '@/types/toast'; const PROOF_QUERY = gql` @@ -22,10 +22,10 @@ interface UseProofProps { } const useProof = ({ fetchUser }: UseProofProps) => { - const user = useAppSelector((state) => state.user); - const userToken = useAppSelector((state) => state.userToken); + const { user } = useUser(); const { data, loading, error, refetch } = useQuery(PROOF_QUERY); const { initPaymentSheet, presentPaymentSheet } = useStripe(); + const { mutateAsync: createPaymentSheet } = usePaymentSheetMutation(); const { scheduleMembershipExpiryNotification, hasPermission } = useNotifications(); const [paymentLoading, setPaymentLoading] = useState(false); // Matches the preselected option in PurchaseButton's radio list @@ -56,10 +56,7 @@ const useProof = ({ fetchUser }: UseProofProps) => { }; const initializePaymentSheet = async () => { - const { paymentIntent, ephemeralKey, customer } = await requestStripePaymentSheet( - userToken as string, - membershipTypeSlug, - ); + const { paymentIntent, ephemeralKey, customer } = await createPaymentSheet(membershipTypeSlug); const { error } = await initPaymentSheet({ merchantDisplayName: 'Det Norske Studentersamfund', diff --git a/src/modules/membership/useUserDelete.tsx b/src/modules/membership/useUserDelete.tsx index f6e889c..f68b8fe 100644 --- a/src/modules/membership/useUserDelete.tsx +++ b/src/modules/membership/useUserDelete.tsx @@ -1,32 +1,12 @@ -import { useNavigation } from '@react-navigation/native'; -import { useEffect } from 'react'; - -import { requestDeleteUser } from '@/api'; -import { useAppDispatch, useAppSelector, usePrevious } from '@/hooks'; +import { useDeleteUserMutation } from '@/queries/useDeleteUserMutation'; const useUserDelete = () => { - const dispatch = useAppDispatch(); - const navigation = useNavigation(); - const isAuthenticated = useAppSelector((store) => store.isAuthenticated); - const isAuthenticatedPrev = usePrevious(isAuthenticated); - const userToken = useAppSelector((store) => store.userToken); - const isDeletingUser = useAppSelector((store) => store.isDeletingUser); - const deleteUserError = useAppSelector((store) => store.deleteUserError); - - const onPress = () => { - dispatch(requestDeleteUser(userToken || '')); - }; - - useEffect(() => { - if (isAuthenticatedPrev !== isAuthenticated && !isAuthenticated) { - navigation.navigate('UserStack', { screen: 'Membership' }); - } - }, [isAuthenticatedPrev, isAuthenticated, navigation]); + const { mutate: deleteAccount, isPending, error } = useDeleteUserMutation(); return { - onPress, - isDeletingUser, - deleteUserError, + onPress: () => deleteAccount(), + isDeletingUser: isPending, + deleteUserError: error, }; }; export default useUserDelete; diff --git a/src/modules/register/useUserRegister.tsx b/src/modules/register/useUserRegister.tsx index 363f268..78fbf0e 100644 --- a/src/modules/register/useUserRegister.tsx +++ b/src/modules/register/useUserRegister.tsx @@ -1,29 +1,19 @@ import { useNavigation } from '@react-navigation/native'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useForm } from 'react-hook-form'; -import { requestRegisterUser } from '@/api'; -import { useAppDispatch, useAppSelector, usePrevious } from '@/hooks'; +import { ApiError } from '@/errors'; +import { useRegisterUserMutation } from '@/queries/useRegisterUserMutation'; +import type { UserRegisterFormValues } from '@/types/forms'; import { ToastProps } from '@/types/toast'; -export interface UserRegisterFormValues { - first_name: string; - last_name: string; - email: string; - phone_number: string; - password: string; -} +export type { UserRegisterFormValues }; const useUserRegister = () => { - const dispatch = useAppDispatch(); const navigation = useNavigation(); - const isAuthenticated = useAppSelector((store) => store.isAuthenticated); - const isRegisteringUser = useAppSelector((store) => store.isRegisteringUser); - const registerError = useAppSelector((store) => store.registerError); + const { mutate: register, isPending, error } = useRegisterUserMutation(); const [toast, setToast] = useState(null); - const isAuthenticatedPrev = usePrevious(isAuthenticated); - const { control, handleSubmit, @@ -32,6 +22,7 @@ const useUserRegister = () => { setError, clearErrors, } = useForm(); + const onSubmit = handleSubmit((values) => { clearErrors(); if (!isValid) { @@ -44,43 +35,28 @@ const useUserRegister = () => { } setToast(null); - dispatch(requestRegisterUser(values)); - - // Display error messages from API on form fields - if (!registerError) return; - Object.keys(registerError).forEach((key) => { - const errorMessages = registerError[key]; - if (!errorMessages || !Array.isArray(errorMessages)) { - return; - } - setError(key as keyof UserRegisterFormValues, { type: 'server', message: errorMessages.join('\n') }); + register(values, { + onSuccess: () => navigation.goBack(), + onError: (registerError) => { + // Map DRF field errors onto the matching form fields + if (!(registerError instanceof ApiError)) { + return; + } + Object.entries(registerError.fields).forEach(([field, messages]) => { + if (Array.isArray(messages)) { + setError(field as keyof UserRegisterFormValues, { type: 'server', message: messages.join('\n') }); + } + }); + }, }); }); - useEffect(() => { - if (isAuthenticatedPrev !== isAuthenticated && isAuthenticated) { - navigation.goBack(); - } - }, [isAuthenticatedPrev, isAuthenticated, navigation]); - - useEffect(() => { - if (!isSubmitted || !registerError) return; - clearErrors(); - Object.keys(registerError).forEach((key) => { - const errorMessages = registerError[key]; - if (!errorMessages || !Array.isArray(errorMessages)) { - return; - } - setError(key as keyof UserRegisterFormValues, { type: 'server', message: errorMessages.join('\n') }); - }); - }, [registerError, isSubmitted, clearErrors, setError]); - return { - isRegisteringUser, + isRegisteringUser: isPending, onSubmit, isValid, isSubmitted, - registerError, + registerError: error, control, errors, setFocus, diff --git a/src/queries/queryClient.test.ts b/src/queries/queryClient.test.ts new file mode 100644 index 0000000..5aad3a2 --- /dev/null +++ b/src/queries/queryClient.test.ts @@ -0,0 +1,43 @@ +import type { Query } from '@tanstack/react-query'; + +import { ApiError } from '@/errors'; +import { appQueryClient, persistOptions } from '@/queries/queryClient'; + +const queryDefaults = appQueryClient.getDefaultOptions().queries!; + +describe('retry', () => { + const retry = queryDefaults.retry as (failureCount: number, error: Error) => boolean; + + it('retries a failed request once', () => { + expect(retry(0, new Error('network down'))).toBe(true); + expect(retry(1, new Error('network down'))).toBe(false); + }); + + it('never retries a 401, whose token is dead', () => { + expect(retry(0, new ApiError({ detail: 'Ugyldig token.' }, 401))).toBe(false); + }); + + it('retries other API errors', () => { + expect(retry(0, new ApiError({ detail: 'Serverfeil' }, 500))).toBe(true); + }); + + it('does not retry mutations', () => { + expect(appQueryClient.getDefaultOptions().mutations?.retry).toBe(0); + }); +}); + +describe('persistOptions', () => { + it('only dehydrates successful queries', () => { + const shouldDehydrateQuery = persistOptions.dehydrateOptions!.shouldDehydrateQuery!; + + expect(shouldDehydrateQuery({ state: { status: 'success' } } as unknown as Query)).toBe(true); + expect(shouldDehydrateQuery({ state: { status: 'error' } } as unknown as Query)).toBe(false); + expect(shouldDehydrateQuery({ state: { status: 'pending' } } as unknown as Query)).toBe(false); + }); + + it('keeps the persisted cache for 90 days of offline proof, within gcTime', () => { + expect(persistOptions.maxAge).toBe(90 * 24 * 60 * 60 * 1000); + // must stay ≥ maxAge; finite values this large overflow setTimeout and GC immediately + expect(queryDefaults.gcTime).toBe(Infinity); + }); +}); diff --git a/src/queries/queryClient.ts b/src/queries/queryClient.ts new file mode 100644 index 0000000..6dcf0f9 --- /dev/null +++ b/src/queries/queryClient.ts @@ -0,0 +1,41 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'; +import { QueryClient } from '@tanstack/react-query'; +import type { PersistQueryClientOptions } from '@tanstack/react-query-persist-client'; + +import { ApiError } from '@/errors'; + +// How long the persisted cache, and thus the offline membership proof, lives +const PERSIST_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000; + +export const appQueryClient = new QueryClient({ + defaultOptions: { + queries: { + // 401 = dead token, retrying can't help + retry: (failureCount, error) => !(error instanceof ApiError && error.status === 401) && failureCount < 1, + // No GC timer (90 days overflows setTimeout), maxAge bounds entry age + gcTime: Infinity, + staleTime: 60 * 1000, + }, + mutations: { + retry: 0, + }, + }, +}); + +const persister = createAsyncStoragePersister({ + storage: AsyncStorage, + key: 'cnapp.queryCache', + // 0 in tests so throttle timers don't hold Jest open + throttleTime: process.env.NODE_ENV === 'test' ? 0 : undefined, +}); + +export const persistOptions: Omit = { + persister, + maxAge: PERSIST_MAX_AGE_MS, + // Bump manually to bust the persisted cache + buster: 'v1', + dehydrateOptions: { + shouldDehydrateQuery: (query) => query.state.status === 'success', + }, +}; diff --git a/src/queries/queryKeys.ts b/src/queries/queryKeys.ts new file mode 100644 index 0000000..cadf7c0 --- /dev/null +++ b/src/queries/queryKeys.ts @@ -0,0 +1,4 @@ +export const queryKeys = { + user: ['user'], + events: ['events'], +} as const; diff --git a/src/queries/useDeleteUserMutation.ts b/src/queries/useDeleteUserMutation.ts new file mode 100644 index 0000000..bdfbb4d --- /dev/null +++ b/src/queries/useDeleteUserMutation.ts @@ -0,0 +1,13 @@ +import { useMutation } from '@tanstack/react-query'; + +import { deleteUser } from '@/api'; +import { useAuth } from '@/auth/AuthContext'; + +export function useDeleteUserMutation() { + const { token, signOut } = useAuth(); + + return useMutation({ + mutationFn: () => deleteUser(token ?? ''), + onSuccess: () => signOut(), + }); +} diff --git a/src/queries/useEvents.ts b/src/queries/useEvents.ts new file mode 100644 index 0000000..7f0401c --- /dev/null +++ b/src/queries/useEvents.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; + +import { fetchEvents } from '@/api'; +import { queryKeys } from '@/queries/queryKeys'; + +export function useEvents() { + return useQuery({ + queryKey: queryKeys.events, + queryFn: fetchEvents, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/src/queries/useLoginMutation.ts b/src/queries/useLoginMutation.ts new file mode 100644 index 0000000..5c5de28 --- /dev/null +++ b/src/queries/useLoginMutation.ts @@ -0,0 +1,18 @@ +import { useMutation } from '@tanstack/react-query'; + +import { login } from '@/api'; +import { useAuth } from '@/auth/AuthContext'; + +interface LoginVariables { + username: string; + password: string; +} + +export function useLoginMutation() { + const { signIn } = useAuth(); + + return useMutation({ + mutationFn: ({ username, password }: LoginVariables) => login(username, password), + onSuccess: ({ token }) => signIn(token), + }); +} diff --git a/src/queries/usePaymentSheetMutation.ts b/src/queries/usePaymentSheetMutation.ts new file mode 100644 index 0000000..e7b1d54 --- /dev/null +++ b/src/queries/usePaymentSheetMutation.ts @@ -0,0 +1,12 @@ +import { useMutation } from '@tanstack/react-query'; + +import { requestStripePaymentSheet } from '@/api'; +import { useAuth } from '@/auth/AuthContext'; + +export function usePaymentSheetMutation() { + const { token } = useAuth(); + + return useMutation({ + mutationFn: (membershipTypeSlug: string) => requestStripePaymentSheet(token ?? '', membershipTypeSlug), + }); +} diff --git a/src/queries/useRegisterUserMutation.ts b/src/queries/useRegisterUserMutation.ts new file mode 100644 index 0000000..f927937 --- /dev/null +++ b/src/queries/useRegisterUserMutation.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { registerUser } from '@/api'; +import { useAuth } from '@/auth/AuthContext'; +import { queryKeys } from '@/queries/queryKeys'; +import type { UserRegisterFormValues } from '@/types/forms'; + +export function useRegisterUserMutation() { + const { signIn } = useAuth(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (values: UserRegisterFormValues) => registerUser(values), + onSuccess: ({ user, token }) => { + // Seed the user before signIn flips isAuthenticated: no spinner flash + queryClient.setQueryData(queryKeys.user, user); + signIn(token); + }, + }); +} diff --git a/src/queries/useUser.ts b/src/queries/useUser.ts new file mode 100644 index 0000000..0ab4e72 --- /dev/null +++ b/src/queries/useUser.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; + +import { fetchUserData } from '@/api'; +import { useAuth } from '@/auth/AuthContext'; +import { ApiError } from '@/errors'; +import { queryKeys } from '@/queries/queryKeys'; + +/** The logged in user. */ +export function useUser() { + const { token, signOut } = useAuth(); + + const query = useQuery({ + queryKey: queryKeys.user, + queryFn: () => fetchUserData(token as string), + enabled: !!token, + // staleTime 0: the cached user renders immediately while a refetch runs + // in the background on mount + staleTime: 0, + }); + + // Revoked token (401) → sign out; network errors never do + const { error } = query; + useEffect(() => { + if (error instanceof ApiError && error.status === 401) { + signOut(); + } + }, [error, signOut]); + + return { ...query, user: query.data ?? null }; +} diff --git a/src/reducers.ts b/src/reducers.ts deleted file mode 100644 index b9b9900..0000000 --- a/src/reducers.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { - LOGIN_REQUEST, - LOGIN_SUCCESS, - LOGIN_FAILURE, - LOGOUT, - USER_DATA_REQUEST, - USER_DATA_SUCCESS, - USER_DATA_FAILURE, - REGISTER_USER_REQUEST, - REGISTER_USER_FAILURE, - REGISTER_USER_SUCCESS, - DELETE_USER_REQUEST, - DELETE_USER_FAILURE, - DELETE_USER_SUCCESS, -} from './actions'; -import type { UserType } from './types/data'; - -interface RootState { - isAuthenticated: boolean; - isLoggingIn: boolean; - isFetchingUserData: boolean; - isRegisteringUser: boolean; - isDeletingUser: boolean; - userToken: string | null; - user: UserType | null; - loginError: Record | null; - userError: Record | null; - registerError: Record | null; - deleteUserError: Record | null; -} - -const initialState: RootState = { - isAuthenticated: false, - isLoggingIn: false, - isFetchingUserData: false, - isRegisteringUser: false, - isDeletingUser: false, - userToken: null, - user: null, - loginError: null, - userError: null, - registerError: null, - deleteUserError: null, -}; - -// FIXME: refactor using redux slices - -export default function appReducers(state = initialState, action: any) { - switch (action.type) { - /* Login */ - case LOGIN_REQUEST: - return { ...state, isLoggingIn: true, loginError: null }; - - case LOGIN_FAILURE: - return { ...state, isLoggingIn: false, loginError: action.loginError }; - - case LOGIN_SUCCESS: - return { ...state, isLoggingIn: false, isAuthenticated: true, userToken: action.token }; - - case LOGOUT: - return initialState; - - /* User data */ - case USER_DATA_REQUEST: - return { ...state, isFetchingUserData: true, userError: null }; - - case USER_DATA_FAILURE: - return { ...state, isFetchingUserData: false, userError: action.userError }; - - case USER_DATA_SUCCESS: - return { ...state, isFetchingUserData: false, user: action.data }; - - /* Register user */ - case REGISTER_USER_REQUEST: - return { ...state, isRegisteringUser: true, registerError: null }; - - case REGISTER_USER_FAILURE: - return { ...state, isRegisteringUser: false, registerError: action.registerError }; - - case REGISTER_USER_SUCCESS: - const userData = action.data; - - const token = action.data.auth_token; - delete userData.auth_token; - - return { ...state, isRegisteringUser: false, isAuthenticated: true, user: userData, userToken: token }; - - /* Delete user */ - case DELETE_USER_REQUEST: - return { ...state, isDeletingUser: true, deleteUserError: null }; - - case DELETE_USER_FAILURE: - return { ...state, isDeletingUser: false, deleteUserError: action.deleteUserError }; - - case DELETE_USER_SUCCESS: - return initialState; - - default: - return state; - } -} diff --git a/src/reduxStore.ts b/src/reduxStore.ts deleted file mode 100644 index de6540e..0000000 --- a/src/reduxStore.ts +++ /dev/null @@ -1,25 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { applyMiddleware, createStore } from 'redux'; -import { persistStore, persistReducer } from 'redux-persist'; -import { thunk } from 'redux-thunk'; - -import rootReducer from './reducers'; - -const persistConfig = { - key: 'root', - storage: AsyncStorage, - blacklist: ['isLoggingIn', 'isFetchingUserData', 'isRegisteringUser', 'loginError'], - // redux-persist never clears its 5s rehydration-watchdog timer, which keeps - // Jest workers alive after tests finish; 0 disables it. The mocked - // AsyncStorage rehydrates instantly, so the watchdog protects nothing there. - timeout: process.env.NODE_ENV === 'test' ? 0 : undefined, -}; - -const persistedReducer = persistReducer(persistConfig, rootReducer); - -export const store = createStore(persistedReducer, applyMiddleware(...[thunk])); -export const persistor = persistStore(store); - -export type AppStore = typeof store; -export type RootState = ReturnType; -export type AppDispatch = AppStore['dispatch']; diff --git a/src/testUtils.tsx b/src/testUtils.tsx index cf8b0f4..e8ac17d 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -2,20 +2,15 @@ import { InMemoryCache } from '@apollo/client'; import type { MockedResponse } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing'; import { NavigationContainer } from '@react-navigation/native'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render } from '@testing-library/react-native'; import React from 'react'; import type { ReactElement } from 'react'; -import { Provider } from 'react-redux'; -import { applyMiddleware, legacy_createStore as createStore } from 'redux'; -import { thunk } from 'redux-thunk'; -import rootReducer from '@/reducers'; +import { AuthProvider } from '@/auth/AuthContext'; +import { queryKeys } from '@/queries/queryKeys'; import type { UserType } from '@/types/data'; -type AppState = ReturnType; - -const initialState: AppState = rootReducer(undefined, { type: '@@TEST_INIT' }); - /** A member with an active membership, for screens that require a logged in user. */ export const testUser: UserType = { username: 'testbruker', @@ -31,33 +26,43 @@ export const testUser: UserType = { }; interface RenderWithProvidersOptions { + /** Auth token the app has stored; being set means the user is logged in. */ initialToken?: string | null; + /** Already-fetched user data, as after a completed /api/me/ request. */ initialUser?: UserType | null; apolloMocks?: readonly MockedResponse[]; } /** - * Renders a screen inside the providers it expects in the real app: - * a fresh (non-persisted) Redux store, Apollo's MockedProvider and a + * Renders a screen inside the providers it expects in the real app: a fresh + * query client (no persister), AuthProvider, Apollo's MockedProvider and a * NavigationContainer. */ export function renderWithProviders( ui: ReactElement, { initialToken = null, initialUser = null, apolloMocks = [] }: RenderWithProvidersOptions = {}, ) { - const preloadedState: AppState = { - ...initialState, - userToken: initialToken, - isAuthenticated: !!initialToken, - user: initialUser, - }; - const store = createStore(rootReducer, preloadedState, applyMiddleware(thunk)); + const queryClient = new QueryClient({ + defaultOptions: { + // No retries, no GC timers holding Jest open, and no mount refetches + // (those would clobber a seeded initialUser with global fetch mock data) + queries: { retry: false, gcTime: Infinity, refetchOnMount: false }, + mutations: { retry: false, gcTime: Infinity }, + }, + }); + if (initialUser) { + queryClient.setQueryData(queryKeys.user, initialUser); + } - return render( + const result = render( - - {ui} - + + + {ui} + + , ); + + return { queryClient, ...result }; } diff --git a/src/types/forms.ts b/src/types/forms.ts new file mode 100644 index 0000000..aba51aa --- /dev/null +++ b/src/types/forms.ts @@ -0,0 +1,7 @@ +export interface UserRegisterFormValues { + first_name: string; + last_name: string; + email: string; + phone_number: string; + password: string; +}