Replace redux with TanStack Query and an auth context
CI / checks (push) Successful in 1m42s

This commit is contained in:
ponas committed 2026-07-31 03:16:32 +02:00
1 parent f18deffa7b
commit ecbeecab23
45 files changed
+1141 -906

No files matched your search

+1 -9
View File
@@ -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
+1 -1
View File
@@ -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'],
+5
View File
@@ -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'),
);
+74 -58
View File
@@ -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",
+7 -6
View File
@@ -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": {
+19 -3
View File
@@ -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(<App />);
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(<App />);
// 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));
});
+9 -9
View File
@@ -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 (
<GestureHandlerRootView>
<StatusBar style="light" />
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<PersistQueryClientProvider client={appQueryClient} persistOptions={persistOptions}>
<AuthProvider>
<ApolloProvider client={apolloClient}>
{/* FIXME: Read urlScheme and merchantIdentifier from .Config */}
<StripeProvider
@@ -45,9 +46,8 @@ export default function App() {
<AppInner />
</StripeProvider>
</ApolloProvider>
</PersistGate>
</Provider>
</AuthProvider>
</PersistQueryClientProvider>
</GestureHandlerRootView>
);
}
/** YOLO */
+2 -2
View File
@@ -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 <Navigation />;
};
-135
View File
@@ -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<string, string>) {
return {
type: REGISTER_USER_FAILURE,
registerError,
};
}
function deleteUserRequest() {
return {
type: DELETE_USER_REQUEST,
};
}
function deleteUserSuccess() {
return {
type: DELETE_USER_SUCCESS,
};
}
function deleteUserFailure(deleteUserError: Record<string, string>) {
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,
};
+92 -110
View File
@@ -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');
});
});
+92 -180
View File
@@ -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<string, UserType> = {
},
};
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<UserType> {
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<void> {
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<EventType[]> {
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;
}
+99
View File
@@ -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 }) => (
<QueryClientProvider client={queryClient}>
<AuthProvider initialToken={initialToken}>{children}</AuthProvider>
</QueryClientProvider>
);
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();
});
+87
View File
@@ -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<AuthContextValue | null>(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<string | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const value = useContext(AuthContext);
if (!value) {
throw new Error('useAuth must be used inside an AuthProvider');
}
return value;
}
+135
View File
@@ -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<string, unknown>) =>
JSON.stringify(Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, JSON.stringify(value)])));
const seedBlob = (fields: Record<string, unknown>) =>
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');
});
+76
View File
@@ -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<MigratedAuth> {
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 };
}
+26
View File
@@ -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();
});
+15
View File
@@ -0,0 +1,15 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
const TOKEN_KEY = 'cnapp.authToken';
export async function getStoredToken(): Promise<string | null> {
return AsyncStorage.getItem(TOKEN_KEY);
}
export async function setStoredToken(token: string | null): Promise<void> {
if (token === null) {
await AsyncStorage.removeItem(TOKEN_KEY);
} else {
await AsyncStorage.setItem(TOKEN_KEY, token);
}
}
+10 -6
View File
@@ -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<string, any> }) => {
const FormErrors = ({ errors }: { errors: Record<string, any> | Error }) => {
if (!errors) {
return [];
}
// ApiError carries the DRF body; unwrap before the instanceof Error check
const fields: Record<string, any> = errors instanceof ApiError ? errors.fields : (errors as Record<string, any>);
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) {
+15
View File
@@ -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<string, unknown>;
status: number;
constructor(fields: Record<string, unknown>, status: number) {
super(`API request failed with status ${status}`);
this.name = 'ApiError';
this.fields = fields;
this.status = status;
}
}
+11 -37
View File
@@ -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<T>(value: T): T | undefined {
const [previous, setPrevious] = useState<T | undefined>(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<RootState> = 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());
}
});
}
+18 -3
View File
@@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useEventList(), { wrapper });
await waitFor(() => expect(result.current.events).toHaveLength(1));
mockFetch.mockRejectedValue(new Error('network down'));
+8 -42
View File
@@ -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<EventType[]>([]);
const [error, setError] = useState<Error | unknown | null>(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,
};
};
+5 -18
View File
@@ -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<LoginFormValues>();
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;
+35 -20
View File
@@ -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(<Membership />, {
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(<Membership />, {
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(<Membership />, {
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(<Membership />, {
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(<Membership />, {
initialToken: 'test-token',
initialUser: testUser,
apolloMocks,
});
fireEvent.press(await findByText('Logg ut'));
expect(await findByText('Bli medlem')).toBeTruthy();
expect(mockNavigate).toHaveBeenCalledWith('UserStack', { screen: 'Membership' });
});
+12 -10
View File
@@ -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 (
<View style={theme.card}>
<ActivityIndicator color={theme.colors.neufOrange} />
</View>
);
}
if (isAuthenticated) {
// Only the very first fetch blocks, refreshes keep the proof mounted
if (isPending) {
return (
<View style={theme.card}>
<ActivityIndicator color={theme.colors.neufOrange} />
</View>
);
}
return (
<>
<Proof
onLogoutPress={onLogoutPress}
fetchUser={fetchUser}
isFetchingUserData={isFetchingUserData}
isRefetching={isRefetching}
onDeletePress={onDeletePress}
/>
<PrivacyButton />
+3 -3
View File
@@ -63,7 +63,7 @@ const renderProof = ({
mocks = apolloMocks,
}: { user?: UserType; fetchUser?: () => void; mocks?: readonly MockedResponse[] } = {}) =>
renderWithProviders(
<Proof onLogoutPress={jest.fn()} fetchUser={fetchUser} isFetchingUserData={false} onDeletePress={jest.fn()} />,
<Proof onLogoutPress={jest.fn()} fetchUser={fetchUser} isRefetching={false} onDeletePress={jest.fn()} />,
{
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(
+3 -3
View File
@@ -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<ConfettiMethods>(null);
const burstRef = useRef<PIConfettiMethods>(null);
const confettiContainerRef = useRef<React.ComponentRef<typeof View>>(null);
@@ -317,7 +317,7 @@ const Proof = ({ onLogoutPress, fetchUser, isFetchingUserData, onDeletePress }:
onRainEnd={onRainEnd}
/>
<View style={styles.container}>
<ScrollView refreshControl={<RefreshControl refreshing={isFetchingUserData} onRefresh={fetchUser} />}>
<ScrollView refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={fetchUser} />}>
<View style={[theme.card, { paddingVertical: 16, backgroundColor: 'transparent' }]}>
<MembershipName user={user} />
<Logo user={user} />
+14 -3
View File
@@ -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;
}
+8 -22
View File
@@ -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;
+5 -8
View File
@@ -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',
+5 -25
View File
@@ -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;
+22 -46
View File
@@ -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<ToastProps | null>(null);
const isAuthenticatedPrev = usePrevious(isAuthenticated);
const {
control,
handleSubmit,
@@ -32,6 +22,7 @@ const useUserRegister = () => {
setError,
clearErrors,
} = useForm<UserRegisterFormValues>();
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,
+43
View File
@@ -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);
});
});
+41
View File
@@ -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<PersistQueryClientOptions, 'queryClient'> = {
persister,
maxAge: PERSIST_MAX_AGE_MS,
// Bump manually to bust the persisted cache
buster: 'v1',
dehydrateOptions: {
shouldDehydrateQuery: (query) => query.state.status === 'success',
},
};
+4
View File
@@ -0,0 +1,4 @@
export const queryKeys = {
user: ['user'],
events: ['events'],
} as const;
+13
View File
@@ -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(),
});
}
+12
View File
@@ -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,
});
}
+18
View File
@@ -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),
});
}
+12
View File
@@ -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),
});
}
+20
View File
@@ -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);
},
});
}
+31
View File
@@ -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 };
}
-101
View File
@@ -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<string, string> | null;
userError: Record<string, string> | null;
registerError: Record<string, string> | null;
deleteUserError: Record<string, string> | 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;
}
}
-25
View File
@@ -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<AppStore['getState']>;
export type AppDispatch = AppStore['dispatch'];
+26 -21
View File
@@ -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<typeof rootReducer>;
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(
<MockedProvider mocks={apolloMocks} cache={new InMemoryCache()}>
<Provider store={store}>
<NavigationContainer>{ui}</NavigationContainer>
</Provider>
<QueryClientProvider client={queryClient}>
<AuthProvider initialToken={initialToken}>
<NavigationContainer>{ui}</NavigationContainer>
</AuthProvider>
</QueryClientProvider>
</MockedProvider>,
);
return { queryClient, ...result };
}
+7
View File
@@ -0,0 +1,7 @@
export interface UserRegisterFormValues {
first_name: string;
last_name: string;
email: string;
phone_number: string;
password: string;
}