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;
+}