This commit is contained in:
45 files changed
+1141
-906
No files matched your search
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
|
||||
Generated
+74
-58
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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 />
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export const queryKeys = {
|
||||
user: ['user'],
|
||||
events: ['events'],
|
||||
} as const;
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface UserRegisterFormValues {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone_number: string;
|
||||
password: string;
|
||||
}
|
||||
Reference in new issue
Block a user