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

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

No files matched your search

+18 -3
View File
@@ -1,3 +1,4 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, renderHook, waitFor } from '@testing-library/react-native';
import React from 'react';
@@ -13,6 +14,17 @@ jest.mock('@/utils', () => ({
const mockFetch = fetchWithTimeout as jest.Mock;
// FlatList spreads cell rendering over 50 ms batch timers; with real timers
// they fire outside act() at random, with fake timers waitFor advances them
// act-wrapped and deterministically
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
const event = {
id: '1',
title: 'Lørdagsquiz',
@@ -88,10 +100,13 @@ it('renders an error state when the API is unreachable', async () => {
expect(await findByText('Kunne ikke hente programmet...')).toBeTruthy();
});
// stuck spinner bug, fix later
it.failing('stops the refresh spinner and shows the error when refresh fails', async () => {
it('stops the refresh spinner and shows the error when refresh fails', async () => {
mockFetch.mockResolvedValueOnce({ json: async () => ({ events: [event] }) });
const { result } = renderHook(() => useEventList());
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } });
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useEventList(), { wrapper });
await waitFor(() => expect(result.current.events).toHaveLength(1));
mockFetch.mockRejectedValue(new Error('network down'));
+8 -42
View File
@@ -1,50 +1,16 @@
import { useEffect, useState } from 'react';
import type { EventType } from '@/types/data';
import { fetchWithTimeout } from '@/utils';
import { useEvents } from '@/queries/useEvents';
const useEventList = () => {
const [loading, setLoading] = useState(false);
const [events, setEvents] = useState<EventType[]>([]);
const [error, setError] = useState<Error | unknown | null>(null);
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = () => {
setRefreshing(true);
fetchEvents();
};
const fetchEvents = async () => {
const url = `${process.env.EXPO_PUBLIC_EVENT_API_URL}?view=compact-app`;
setLoading(true);
let response;
let fetchedEvents;
try {
const res = await fetchWithTimeout(url);
response = await res.json();
fetchedEvents = response.events;
} catch (err) {
setError(err);
setLoading(false);
return;
}
setEvents(fetchedEvents);
setError(response.error || null);
setLoading(false);
setRefreshing(false);
};
useEffect(() => {
fetchEvents();
}, []);
const { data, error, isPending, isRefetching, refetch } = useEvents();
return {
handleRefresh,
handleRefresh: () => {
refetch();
},
error,
events,
refreshing,
loading,
events: data ?? [],
refreshing: isRefetching,
loading: isPending,
};
};
+5 -18
View File
@@ -1,9 +1,7 @@
import { useNavigation } from '@react-navigation/native';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { requestLogin } from '@/api';
import { useAppDispatch, useAppSelector } from '@/hooks';
import { useLoginMutation } from '@/queries/useLoginMutation';
interface LoginFormValues {
email: string;
@@ -12,12 +10,7 @@ interface LoginFormValues {
const useLogin = () => {
const navigation = useNavigation();
const dispatch = useAppDispatch();
const isAuthenticated = useAppSelector((store) => store.isAuthenticated);
const isLoggingIn = useAppSelector((store) => store.isLoggingIn);
const loginError = useAppSelector((store) => store.loginError);
const { mutate: login, isPending, error } = useLoginMutation();
const {
control,
@@ -27,17 +20,11 @@ const useLogin = () => {
} = useForm<LoginFormValues>();
const onSubmit = handleSubmit(({ email, password }) => {
dispatch(requestLogin(email, password));
// goBack works since Login is always pushed from Membership
login({ username: email, password }, { onSuccess: () => navigation.goBack() });
});
useEffect(() => {
if (isAuthenticated) {
// navigation.goBack onLogin works since LoginScreen is allways navigated to from MembershipScreen
navigation.goBack();
}
}, [isAuthenticated, navigation]);
return { onSubmit, isLoggingIn, isSubmitted, setFocus, control, errors: { ...errors }, loginError };
return { onSubmit, isLoggingIn: isPending, isSubmitted, setFocus, control, errors: { ...errors }, loginError: error };
};
export default useLogin;
+35 -20
View File
@@ -7,6 +7,12 @@ import Membership from '@/modules/membership/Membership';
import { renderWithProviders, testUser } from '@/testUtils';
import type { UserType } from '@/types/data';
const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => ({ navigate: mockNavigate }),
}));
// Must match PROOF_QUERY in useProof.tsx
const PROOF_QUERY = gql`
{
@@ -36,14 +42,6 @@ it('shows the membership pitch when logged out', async () => {
it('lets a logged in non-member pick a membership type and shows its price', async () => {
const nonMember: UserType = { ...testUser, is_member: false, last_membership: null };
// Serve the non-member from the fake API so the refetch on mount does not
// clobber the preloaded state.
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
status: 200,
json: async () => nonMember,
});
const { findByText, getByText } = renderWithProviders(<Membership />, {
initialToken: 'test-token',
initialUser: nonMember,
@@ -81,14 +79,6 @@ it('lets a logged in non-member pick a membership type and shows its price', asy
});
it('shows the membership proof when logged in', async () => {
// Mounting Membership with a token triggers requestUserData; serve the
// logged in user from the fake API so the refetch does not clobber state.
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
status: 200,
json: async () => testUser,
});
const { findByText } = renderWithProviders(<Membership />, {
initialToken: 'test-token',
initialUser: testUser,
@@ -126,9 +116,6 @@ it('refetches and renders updated user data on pull-to-refresh', async () => {
const fetchMock = global.fetch as jest.Mock;
const nonMember: UserType = { ...testUser, is_member: false, last_membership: null };
// the refetch on mount still sees the non-member
fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => nonMember });
const { findByText, UNSAFE_getByType } = renderWithProviders(<Membership />, {
initialToken: 'test-token',
initialUser: nonMember,
@@ -136,7 +123,7 @@ it('refetches and renders updated user data on pull-to-refresh', async () => {
});
expect(await findByText('Velg medlemskapstype')).toBeTruthy();
// but by the next pull-to-refresh the membership has been bought elsewhere
// the server now reports an active membership, bought elsewhere
fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => testUser });
fireEvent(UNSAFE_getByType(RefreshControl), 'refresh');
@@ -149,3 +136,31 @@ it('refetches and renders updated user data on pull-to-refresh', async () => {
}),
);
});
it('signs out when the stored token has been revoked', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ detail: 'Ugyldig token.' }),
});
const { findByText } = renderWithProviders(<Membership />, {
initialToken: 'revoked-token',
apolloMocks,
});
expect(await findByText('Bli medlem')).toBeTruthy();
});
it('logs out and shows the logged-out membership screen again', async () => {
const { findByText } = renderWithProviders(<Membership />, {
initialToken: 'test-token',
initialUser: testUser,
apolloMocks,
});
fireEvent.press(await findByText('Logg ut'));
expect(await findByText('Bli medlem')).toBeTruthy();
expect(mockNavigate).toHaveBeenCalledWith('UserStack', { screen: 'Membership' });
});
+12 -10
View File
@@ -10,7 +10,8 @@ import useMembership from './useMembership';
const Membership = () => {
const {
isFetchingUserData,
isPending,
isRefetching,
onRegisterPress,
onLoginPress,
onLogoutPress,
@@ -19,21 +20,22 @@ const Membership = () => {
fetchUser,
} = useMembership();
if (isFetchingUserData) {
return (
<View style={theme.card}>
<ActivityIndicator color={theme.colors.neufOrange} />
</View>
);
}
if (isAuthenticated) {
// Only the very first fetch blocks, refreshes keep the proof mounted
if (isPending) {
return (
<View style={theme.card}>
<ActivityIndicator color={theme.colors.neufOrange} />
</View>
);
}
return (
<>
<Proof
onLogoutPress={onLogoutPress}
fetchUser={fetchUser}
isFetchingUserData={isFetchingUserData}
isRefetching={isRefetching}
onDeletePress={onDeletePress}
/>
<PrivacyButton />
+3 -3
View File
@@ -63,7 +63,7 @@ const renderProof = ({
mocks = apolloMocks,
}: { user?: UserType; fetchUser?: () => void; mocks?: readonly MockedResponse[] } = {}) =>
renderWithProviders(
<Proof onLogoutPress={jest.fn()} fetchUser={fetchUser} isFetchingUserData={false} onDeletePress={jest.fn()} />,
<Proof onLogoutPress={jest.fn()} fetchUser={fetchUser} isRefetching={false} onDeletePress={jest.fn()} />,
{
initialToken: 'test-token',
initialUser: user,
@@ -133,10 +133,10 @@ it('purchases the preselected membership through the stripe payment sheet', asyn
it('purchases the selected membership type, not the default', async () => {
mockPaymentSheetResponse();
const { findByText, getByText } = renderProof();
const { findByText } = renderProof();
fireEvent.press(await findByText('Student'));
fireEvent.press(getByText('Kjøp medlemskap (150 NOK)'));
fireEvent.press(await findByText('Kjøp medlemskap (150 NOK)'));
expect(await findByText('Your order is confirmed!')).toBeTruthy();
expect(fetchMock).toHaveBeenCalledWith(
+3 -3
View File
@@ -254,10 +254,10 @@ const NotificationToggle = ({ user }: { user: UserType }) => {
interface ProofProps {
onLogoutPress: () => void;
fetchUser: () => void;
isFetchingUserData: boolean;
isRefetching: boolean;
onDeletePress: () => void;
}
const Proof = ({ onLogoutPress, fetchUser, isFetchingUserData, onDeletePress }: ProofProps) => {
const Proof = ({ onLogoutPress, fetchUser, isRefetching, onDeletePress }: ProofProps) => {
const confettiRef = useRef<ConfettiMethods>(null);
const burstRef = useRef<PIConfettiMethods>(null);
const confettiContainerRef = useRef<React.ComponentRef<typeof View>>(null);
@@ -317,7 +317,7 @@ const Proof = ({ onLogoutPress, fetchUser, isFetchingUserData, onDeletePress }:
onRainEnd={onRainEnd}
/>
<View style={styles.container}>
<ScrollView refreshControl={<RefreshControl refreshing={isFetchingUserData} onRefresh={fetchUser} />}>
<ScrollView refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={fetchUser} />}>
<View style={[theme.card, { paddingVertical: 16, backgroundColor: 'transparent' }]}>
<MembershipName user={user} />
<Logo user={user} />
+14 -3
View File
@@ -1,18 +1,29 @@
import React from 'react';
import { useNavigation } from '@react-navigation/native';
import React, { useEffect } from 'react';
import { ActivityIndicator, ScrollView, Text, TextInput, View, StyleSheet } from 'react-native';
import { useAuth } from '@/auth/AuthContext';
import Button from '@/components/Button';
import FormErrors from '@/components/FormErrors';
import { useAppSelector } from '@/hooks';
import { useUser } from '@/queries/useUser';
import theme from '@/theme';
import useUserDelete from './useUserDelete';
const UserDelete = () => {
const [value, onChangeText] = React.useState('');
const user = useAppSelector((state) => state.user);
const navigation = useNavigation();
const { isAuthenticated } = useAuth();
const { user } = useUser();
const { onPress, isDeletingUser, deleteUserError } = useUserDelete();
// Not a screen for logged-out users; this also leaves after a successful deletion
useEffect(() => {
if (!isAuthenticated) {
navigation.navigate('UserStack', { screen: 'Membership' });
}
}, [isAuthenticated, navigation]);
if (!user) {
return null;
}
+8 -22
View File
@@ -1,44 +1,30 @@
import { useNavigation } from '@react-navigation/native';
import { useEffect, useCallback } from 'react';
import { logout } from '@/actions';
import { requestUserData } from '@/api';
import { useAppDispatch, useAppSelector, usePrevious } from '@/hooks';
import { useAuth } from '@/auth/AuthContext';
import { useUser } from '@/queries/useUser';
const useMembership = () => {
const navigation = useNavigation();
const dispatch = useAppDispatch();
const isAuthenticated = useAppSelector((store) => store.isAuthenticated);
const isFetchingUserData = useAppSelector((store) => store.isFetchingUserData);
const userToken = useAppSelector((store) => store.userToken);
const prevUserToken = usePrevious(userToken);
const { isAuthenticated, signOut } = useAuth();
const { isPending, isRefetching, refetch } = useUser();
const onLogoutPress = () => {
dispatch(logout());
signOut();
navigation.navigate('UserStack', { screen: 'Membership' });
};
const onRegisterPress = () => navigation.navigate('UserStack', { screen: 'UserRegister' });
const onLoginPress = () => navigation.navigate('UserStack', { screen: 'Login' });
const onDeletePress = () => navigation.navigate('UserStack', { screen: 'UserDelete' });
const fetchUser = useCallback(() => {
dispatch(requestUserData(userToken || ''));
}, [dispatch, userToken]);
useEffect(() => {
if (userToken !== prevUserToken && userToken) {
fetchUser();
}
}, [userToken, prevUserToken, fetchUser]);
return {
isAuthenticated,
isFetchingUserData,
userToken,
isPending,
isRefetching,
onLogoutPress,
onRegisterPress,
onDeletePress,
onLoginPress,
fetchUser,
fetchUser: refetch,
};
};
export default useMembership;
+5 -8
View File
@@ -3,9 +3,9 @@ import { useStripe } from '@stripe/stripe-react-native';
import Constants from 'expo-constants';
import { useEffect, useState } from 'react';
import { requestStripePaymentSheet } from '@/api';
import { useAppSelector } from '@/hooks';
import { useNotifications } from '@/hooks/useNotifications';
import { usePaymentSheetMutation } from '@/queries/usePaymentSheetMutation';
import { useUser } from '@/queries/useUser';
import { ToastProps } from '@/types/toast';
const PROOF_QUERY = gql`
@@ -22,10 +22,10 @@ interface UseProofProps {
}
const useProof = ({ fetchUser }: UseProofProps) => {
const user = useAppSelector((state) => state.user);
const userToken = useAppSelector((state) => state.userToken);
const { user } = useUser();
const { data, loading, error, refetch } = useQuery(PROOF_QUERY);
const { initPaymentSheet, presentPaymentSheet } = useStripe();
const { mutateAsync: createPaymentSheet } = usePaymentSheetMutation();
const { scheduleMembershipExpiryNotification, hasPermission } = useNotifications();
const [paymentLoading, setPaymentLoading] = useState(false);
// Matches the preselected option in PurchaseButton's radio list
@@ -56,10 +56,7 @@ const useProof = ({ fetchUser }: UseProofProps) => {
};
const initializePaymentSheet = async () => {
const { paymentIntent, ephemeralKey, customer } = await requestStripePaymentSheet(
userToken as string,
membershipTypeSlug,
);
const { paymentIntent, ephemeralKey, customer } = await createPaymentSheet(membershipTypeSlug);
const { error } = await initPaymentSheet({
merchantDisplayName: 'Det Norske Studentersamfund',
+5 -25
View File
@@ -1,32 +1,12 @@
import { useNavigation } from '@react-navigation/native';
import { useEffect } from 'react';
import { requestDeleteUser } from '@/api';
import { useAppDispatch, useAppSelector, usePrevious } from '@/hooks';
import { useDeleteUserMutation } from '@/queries/useDeleteUserMutation';
const useUserDelete = () => {
const dispatch = useAppDispatch();
const navigation = useNavigation();
const isAuthenticated = useAppSelector((store) => store.isAuthenticated);
const isAuthenticatedPrev = usePrevious(isAuthenticated);
const userToken = useAppSelector((store) => store.userToken);
const isDeletingUser = useAppSelector((store) => store.isDeletingUser);
const deleteUserError = useAppSelector((store) => store.deleteUserError);
const onPress = () => {
dispatch(requestDeleteUser(userToken || ''));
};
useEffect(() => {
if (isAuthenticatedPrev !== isAuthenticated && !isAuthenticated) {
navigation.navigate('UserStack', { screen: 'Membership' });
}
}, [isAuthenticatedPrev, isAuthenticated, navigation]);
const { mutate: deleteAccount, isPending, error } = useDeleteUserMutation();
return {
onPress,
isDeletingUser,
deleteUserError,
onPress: () => deleteAccount(),
isDeletingUser: isPending,
deleteUserError: error,
};
};
export default useUserDelete;
+22 -46
View File
@@ -1,29 +1,19 @@
import { useNavigation } from '@react-navigation/native';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { requestRegisterUser } from '@/api';
import { useAppDispatch, useAppSelector, usePrevious } from '@/hooks';
import { ApiError } from '@/errors';
import { useRegisterUserMutation } from '@/queries/useRegisterUserMutation';
import type { UserRegisterFormValues } from '@/types/forms';
import { ToastProps } from '@/types/toast';
export interface UserRegisterFormValues {
first_name: string;
last_name: string;
email: string;
phone_number: string;
password: string;
}
export type { UserRegisterFormValues };
const useUserRegister = () => {
const dispatch = useAppDispatch();
const navigation = useNavigation();
const isAuthenticated = useAppSelector((store) => store.isAuthenticated);
const isRegisteringUser = useAppSelector((store) => store.isRegisteringUser);
const registerError = useAppSelector((store) => store.registerError);
const { mutate: register, isPending, error } = useRegisterUserMutation();
const [toast, setToast] = useState<ToastProps | null>(null);
const isAuthenticatedPrev = usePrevious(isAuthenticated);
const {
control,
handleSubmit,
@@ -32,6 +22,7 @@ const useUserRegister = () => {
setError,
clearErrors,
} = useForm<UserRegisterFormValues>();
const onSubmit = handleSubmit((values) => {
clearErrors();
if (!isValid) {
@@ -44,43 +35,28 @@ const useUserRegister = () => {
}
setToast(null);
dispatch(requestRegisterUser(values));
// Display error messages from API on form fields
if (!registerError) return;
Object.keys(registerError).forEach((key) => {
const errorMessages = registerError[key];
if (!errorMessages || !Array.isArray(errorMessages)) {
return;
}
setError(key as keyof UserRegisterFormValues, { type: 'server', message: errorMessages.join('\n') });
register(values, {
onSuccess: () => navigation.goBack(),
onError: (registerError) => {
// Map DRF field errors onto the matching form fields
if (!(registerError instanceof ApiError)) {
return;
}
Object.entries(registerError.fields).forEach(([field, messages]) => {
if (Array.isArray(messages)) {
setError(field as keyof UserRegisterFormValues, { type: 'server', message: messages.join('\n') });
}
});
},
});
});
useEffect(() => {
if (isAuthenticatedPrev !== isAuthenticated && isAuthenticated) {
navigation.goBack();
}
}, [isAuthenticatedPrev, isAuthenticated, navigation]);
useEffect(() => {
if (!isSubmitted || !registerError) return;
clearErrors();
Object.keys(registerError).forEach((key) => {
const errorMessages = registerError[key];
if (!errorMessages || !Array.isArray(errorMessages)) {
return;
}
setError(key as keyof UserRegisterFormValues, { type: 'server', message: errorMessages.join('\n') });
});
}, [registerError, isSubmitted, clearErrors, setError]);
return {
isRegisteringUser,
isRegisteringUser: isPending,
onSubmit,
isValid,
isSubmitted,
registerError,
registerError: error,
control,
errors,
setFocus,