Replace apollo with tanstack query

This commit is contained in:
ponas committed 2026-08-01 16:00:54 +02:00
1 parent d785ad4c1d
commit a35730f418
12 files changed
+135 -326

No files matched your search

+8 -16
View File
@@ -1,4 +1,3 @@
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';
@@ -24,11 +23,6 @@ Notifications.setNotificationHandler({
}),
});
const apolloClient = new ApolloClient({
link: new HttpLink({ uri: `${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/api/graphql/` }),
cache: new InMemoryCache(),
});
export default function App() {
useDeepLinking();
return (
@@ -36,16 +30,14 @@ export default function App() {
<StatusBar style="light" />
<PersistQueryClientProvider client={appQueryClient} persistOptions={persistOptions}>
<AuthProvider>
<ApolloProvider client={apolloClient}>
{/* FIXME: Read urlScheme and merchantIdentifier from .Config */}
<StripeProvider
publishableKey={process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY || ''}
urlScheme={Constants.expoConfig?.scheme as string}
merchantIdentifier="merchant.no.neuf.chateau" // https://developer.apple.com/account/resources/identifiers/list/merchant
>
<AppInner />
</StripeProvider>
</ApolloProvider>
{/* FIXME: Read urlScheme and merchantIdentifier from .Config */}
<StripeProvider
publishableKey={process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY || ''}
urlScheme={Constants.expoConfig?.scheme as string}
merchantIdentifier="merchant.no.neuf.chateau" // https://developer.apple.com/account/resources/identifiers/list/merchant
>
<AppInner />
</StripeProvider>
</AuthProvider>
</PersistQueryClientProvider>
</GestureHandlerRootView>
+38 -1
View File
@@ -1,4 +1,12 @@
import { deleteUser, fetchEvents, fetchUserData, login, registerUser, requestStripePaymentSheet } from '@/api';
import {
deleteUser,
fetchEvents,
fetchMembershipTypes,
fetchUserData,
login,
registerUser,
requestStripePaymentSheet,
} from '@/api';
import { ApiError } from '@/errors';
const fetchMock = global.fetch as jest.Mock;
@@ -172,6 +180,35 @@ describe('fetchEvents', () => {
});
});
describe('fetchMembershipTypes', () => {
it('posts the GraphQL query and resolves the membership types', async () => {
const membershipTypes = [{ slug: 'regular', price: 30000 }];
mockResponseOnce({ data: { membershipTypes } });
await expect(fetchMembershipTypes()).resolves.toEqual(membershipTypes);
expect(fetchMock).toHaveBeenCalledWith(
'https://dusken.example.test/api/graphql/',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ query: '{ membershipTypes { slug price } }' }),
}),
);
});
it('throws when the API reports a GraphQL error', async () => {
mockResponseOnce({ data: null, errors: [{ message: 'Cannot query field "membershipTypes"' }] });
await expect(fetchMembershipTypes()).rejects.toThrow('Kunne ikke hente priser');
});
it('throws on a non-ok response', async () => {
mockResponseOnce({ data: { membershipTypes: [] } }, false, 500);
await expect(fetchMembershipTypes()).rejects.toThrow('Kunne ikke hente priser');
});
});
describe('requestStripePaymentSheet', () => {
it('posts the membership type and returns the payment sheet parameters', async () => {
const sheet = { paymentIntent: 'pi_secret', ephemeralKey: 'ek_secret', customer: 'cus_123' };
+15 -1
View File
@@ -1,5 +1,5 @@
import { ApiError } from '@/errors';
import type { EventType, UserType } from '@/types/data';
import type { EventType, MembershipTypeOption, UserType } from '@/types/data';
import type { UserRegisterFormValues } from '@/types/forms';
import { fetchWithTimeout } from '@/utils';
@@ -115,6 +115,20 @@ export async function fetchEvents(): Promise<EventType[]> {
return events;
}
export async function fetchMembershipTypes(): Promise<MembershipTypeOption[]> {
const response = await fetchWithTimeout(`${process.env.EXPO_PUBLIC_DUSKEN_API_URL}/api/graphql/`, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ query: '{ membershipTypes { slug price } }' }),
});
const json = await response.json();
const membershipTypes = response.ok ? json?.data?.membershipTypes : null;
if (!membershipTypes) {
throw new Error('Kunne ikke hente priser');
}
return membershipTypes;
}
/** 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/`, {
+8 -41
View File
@@ -1,10 +1,9 @@
import { gql } from '@apollo/client';
import { fireEvent } from '@testing-library/react-native';
import React from 'react';
import { RefreshControl } from 'react-native';
import Membership from '@/modules/membership/Membership';
import { renderWithProviders, testUser } from '@/testUtils';
import { mockMembershipTypesOnce, renderWithProviders, testUser } from '@/testUtils';
import type { UserType } from '@/types/data';
const mockNavigate = jest.fn();
@@ -13,25 +12,6 @@ jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({ navigate: mockNavigate }),
}));
// Must match PROOF_QUERY in useProof.tsx
const PROOF_QUERY = gql`
{
membershipTypes {
slug
price
}
}
`;
const apolloMocks = [
{
request: { query: PROOF_QUERY },
result: {
data: { membershipTypes: [{ __typename: 'MembershipType', slug: 'standard', price: 25000 }] },
},
},
];
it('shows the membership pitch when logged out', async () => {
const { findByText } = renderWithProviders(<Membership />);
@@ -42,23 +22,10 @@ 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 };
mockMembershipTypesOnce();
const { findByText, getByText } = renderWithProviders(<Membership />, {
initialToken: 'test-token',
initialUser: nonMember,
apolloMocks: [
{
request: { query: PROOF_QUERY },
result: {
data: {
membershipTypes: [
{ __typename: 'MembershipType', slug: 'student', price: 15000 },
{ __typename: 'MembershipType', slug: 'regular', price: 30000 },
{ __typename: 'MembershipType', slug: 'support', price: 50000 },
],
},
},
},
],
});
// All three membership types are offered
@@ -79,10 +46,10 @@ 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 () => {
mockMembershipTypesOnce();
const { findByText } = renderWithProviders(<Membership />, {
initialToken: 'test-token',
initialUser: testUser,
apolloMocks,
});
expect(await findByText('MEDLEM')).toBeTruthy();
@@ -90,15 +57,16 @@ it('shows the membership proof when logged in', async () => {
});
it('fetches the user with the stored token when only the token survives a cold start', async () => {
(global.fetch as jest.Mock).mockResolvedValue({
// The user fetch resolves first; Proof then mounts and fetches membership types
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => testUser,
});
mockMembershipTypesOnce();
const { findByText } = renderWithProviders(<Membership />, {
initialToken: 'test-token',
apolloMocks,
});
expect(await findByText('MEDLEM')).toBeTruthy();
@@ -116,10 +84,10 @@ 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 };
mockMembershipTypesOnce();
const { findByText, UNSAFE_getByType } = renderWithProviders(<Membership />, {
initialToken: 'test-token',
initialUser: nonMember,
apolloMocks,
});
expect(await findByText('Velg medlemskapstype')).toBeTruthy();
@@ -146,17 +114,16 @@ it('signs out when the stored token has been revoked', async () => {
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 () => {
mockMembershipTypesOnce();
const { findByText } = renderWithProviders(<Membership />, {
initialToken: 'test-token',
initialUser: testUser,
apolloMocks,
});
fireEvent.press(await findByText('Logg ut'));
+14 -43
View File
@@ -1,11 +1,9 @@
import { gql } from '@apollo/client';
import type { MockedResponse } from '@apollo/client/testing';
import { fireEvent, waitFor } from '@testing-library/react-native';
import * as Notifications from 'expo-notifications';
import React from 'react';
import Proof from '@/modules/membership/Proof';
import { renderWithProviders, testUser } from '@/testUtils';
import { mockMembershipTypesOnce, renderWithProviders, testUser } from '@/testUtils';
import type { UserType } from '@/types/data';
// The setup-wide stripe mock resolves initPaymentSheet/presentPaymentSheet
@@ -24,31 +22,6 @@ const mockPaymentSheetResponse = () =>
json: async () => ({ paymentIntent: 'pi_secret', ephemeralKey: 'ek_secret', customer: 'cus_123' }),
});
// Must match PROOF_QUERY in useProof.tsx
const PROOF_QUERY = gql`
{
membershipTypes {
slug
price
}
}
`;
const apolloMocks = [
{
request: { query: PROOF_QUERY },
result: {
data: {
membershipTypes: [
{ __typename: 'MembershipType', slug: 'student', price: 15000 },
{ __typename: 'MembershipType', slug: 'regular', price: 30000 },
{ __typename: 'MembershipType', slug: 'support', price: 50000 },
],
},
},
},
];
const nonMember: UserType = { ...testUser, is_member: false, last_membership: null };
const expiredMember: UserType = {
@@ -57,19 +30,18 @@ const expiredMember: UserType = {
last_membership: { membership_type: 'standard', end_date: '2020-01-01' },
};
const renderProof = ({
user = nonMember,
fetchUser = jest.fn(),
mocks = apolloMocks,
}: { user?: UserType; fetchUser?: () => void; mocks?: readonly MockedResponse[] } = {}) =>
renderWithProviders(
// The membership type query fires on mount, so its response must be queued
// before render; later mockResolvedValueOnce calls queue up behind it.
const renderProof = ({ user = nonMember, fetchUser = jest.fn() }: { user?: UserType; fetchUser?: () => void } = {}) => {
mockMembershipTypesOnce();
return renderWithProviders(
<Proof onLogoutPress={jest.fn()} fetchUser={fetchUser} isRefetching={false} onDeletePress={jest.fn()} />,
{
initialToken: 'test-token',
initialUser: user,
apolloMocks: mocks,
},
);
};
beforeEach(() => {
jest.clearAllMocks();
@@ -87,9 +59,8 @@ it('disables the purchase button without showing a price while membership types
});
it('shows an error with retry instead of a price when membership types fail to load', async () => {
const { findByText, getByText, getByRole } = renderProof({
mocks: [{ request: { query: PROOF_QUERY }, error: new Error('network down') }, ...apolloMocks],
});
fetchMock.mockRejectedValueOnce(new Error('network down'));
const { findByText, getByText, getByRole } = renderProof();
expect(await findByText('Kunne ikke hente priser')).toBeTruthy();
expect(getByRole('button', { name: 'Kjøp medlemskap' })).toBeDisabled();
@@ -101,8 +72,8 @@ it('shows an error with retry instead of a price when membership types fail to l
});
it('purchases the preselected membership through the stripe payment sheet', async () => {
mockPaymentSheetResponse();
const { findByText } = renderProof();
mockPaymentSheetResponse();
// 'Ordinær' (slug: regular) is preselected; the button enables once the
// membership type query has resolved and its price is shown
@@ -132,8 +103,8 @@ it('purchases the preselected membership through the stripe payment sheet', asyn
});
it('purchases the selected membership type, not the default', async () => {
mockPaymentSheetResponse();
const { findByText } = renderProof();
mockPaymentSheetResponse();
fireEvent.press(await findByText('Student'));
fireEvent.press(await findByText('Kjøp medlemskap (150 NOK)'));
@@ -146,11 +117,11 @@ it('purchases the selected membership type, not the default', async () => {
});
it('shows the stripe error when the payment sheet is not completed', async () => {
mockPaymentSheetResponse();
presentPaymentSheet.mockResolvedValueOnce({
error: { code: 'Canceled', message: 'The payment flow has been canceled' },
});
const { findByText, queryByText } = renderProof();
mockPaymentSheetResponse();
fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
@@ -160,12 +131,12 @@ it('shows the stripe error when the payment sheet is not completed', async () =>
it('shows an error and does not present the sheet when the backend rejects the purchase', async () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
const { findByText, getByRole } = renderProof();
fetchMock.mockResolvedValueOnce({
ok: false,
status: 400,
json: async () => ({ detail: 'Ugyldig medlemskapstype' }),
});
const { findByText, getByRole } = renderProof();
fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
@@ -178,9 +149,9 @@ it('shows an error and does not present the sheet when the backend rejects the p
it('shows an error and does not present the sheet when stripe fails to initialise it', async () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
mockPaymentSheetResponse();
initPaymentSheet.mockResolvedValueOnce({ error: { code: 'Failed', message: 'Invalid client secret' } });
const { findByText } = renderProof();
mockPaymentSheetResponse();
fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
+5 -16
View File
@@ -1,29 +1,20 @@
import { gql, useQuery } from '@apollo/client';
import { useStripe } from '@stripe/stripe-react-native';
import Constants from 'expo-constants';
import { useEffect, useState } from 'react';
import { useNotifications } from '@/hooks/useNotifications';
import { useMembershipTypes } from '@/queries/useMembershipTypes';
import { usePaymentSheetMutation } from '@/queries/usePaymentSheetMutation';
import { useUser } from '@/queries/useUser';
import { ToastProps } from '@/types/toast';
const PROOF_QUERY = gql`
{
membershipTypes {
slug
price
}
}
`;
interface UseProofProps {
fetchUser: () => void;
}
const useProof = ({ fetchUser }: UseProofProps) => {
const { user } = useUser();
const { data, loading, error, refetch } = useQuery(PROOF_QUERY);
const { data: membershipTypes, isPending, isError, refetch } = useMembershipTypes();
const { initPaymentSheet, presentPaymentSheet } = useStripe();
const { mutateAsync: createPaymentSheet } = usePaymentSheetMutation();
const { scheduleMembershipExpiryNotification, hasPermission } = useNotifications();
@@ -35,9 +26,7 @@ const useProof = ({ fetchUser }: UseProofProps) => {
// null until the query has delivered a price for the selected type; the
// purchase button stays disabled rather than showing a guessed amount
const membershipPrice =
data?.membershipTypes?.find(
(membershipTypeOption: { slug: string; price: number }) => membershipTypeOption.slug === membershipTypeSlug,
)?.price ?? null;
membershipTypes?.find((membershipTypeOption) => membershipTypeOption.slug === membershipTypeSlug)?.price ?? null;
const openPaymentSheet = async () => {
setPaymentLoading(true);
@@ -92,9 +81,9 @@ const useProof = ({ fetchUser }: UseProofProps) => {
}, [membershipType, membershipEndDate, fetchUser, hasPermission, scheduleMembershipExpiryNotification]);
return {
isLoadingMembershipType: loading,
isLoadingMembershipType: isPending,
membershipPrice: membershipPrice != null ? membershipPrice / 100 : null,
membershipTypesError: !!error,
membershipTypesError: isError,
refetchMembershipTypes: refetch,
onPurchasePress: openPaymentSheet,
paymentLoading,
+1
View File
@@ -1,4 +1,5 @@
export const queryKeys = {
user: ['user'],
events: ['events'],
membershipTypes: ['membershipTypes'],
} as const;
+13
View File
@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { fetchMembershipTypes } from '@/api';
import { queryKeys } from '@/queries/queryKeys';
/** The purchasable membership types with prices. */
export function useMembershipTypes() {
return useQuery({
queryKey: queryKeys.membershipTypes,
queryFn: fetchMembershipTypes,
staleTime: 5 * 60 * 1000,
});
}
+24 -15
View File
@@ -1,6 +1,3 @@
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';
@@ -9,7 +6,7 @@ import type { ReactElement } from 'react';
import { AuthProvider } from '@/auth/AuthContext';
import { queryKeys } from '@/queries/queryKeys';
import type { UserType } from '@/types/data';
import type { MembershipTypeOption, UserType } from '@/types/data';
/** A member with an active membership, for screens that require a logged in user. */
export const testUser: UserType = {
@@ -25,22 +22,36 @@ export const testUser: UserType = {
},
};
/** The purchase options offered in the app, as served by /api/graphql/. */
export const testMembershipTypes: MembershipTypeOption[] = [
{ slug: 'student', price: 15000 },
{ slug: 'regular', price: 30000 },
{ slug: 'support', price: 50000 },
];
/** Queue a successful /api/graphql/ membership types response on the fetch mock. */
export function mockMembershipTypesOnce(membershipTypes: MembershipTypeOption[] = testMembershipTypes) {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ data: { membershipTypes } }),
});
}
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
* query client (no persister), AuthProvider, Apollo's MockedProvider and a
* NavigationContainer.
* query client (no persister), AuthProvider and a NavigationContainer.
*/
export function renderWithProviders(
ui: ReactElement,
{ initialToken = null, initialUser = null, apolloMocks = [] }: RenderWithProvidersOptions = {},
{ initialToken = null, initialUser = null }: RenderWithProvidersOptions = {},
) {
const queryClient = new QueryClient({
defaultOptions: {
@@ -55,13 +66,11 @@ export function renderWithProviders(
}
const result = render(
<MockedProvider mocks={apolloMocks} cache={new InMemoryCache()}>
<QueryClientProvider client={queryClient}>
<AuthProvider initialToken={initialToken}>
<NavigationContainer>{ui}</NavigationContainer>
</AuthProvider>
</QueryClientProvider>
</MockedProvider>,
<QueryClientProvider client={queryClient}>
<AuthProvider initialToken={initialToken}>
<NavigationContainer>{ui}</NavigationContainer>
</AuthProvider>
</QueryClientProvider>,
);
return { queryClient, ...result };
+6
View File
@@ -27,6 +27,12 @@ export interface MembershipType {
end_date: string;
}
/** A purchasable membership type; price is in øre. */
export interface MembershipTypeOption {
slug: string;
price: number;
}
export interface UserType {
first_name: string;
last_name: string;