From a35730f418effe6a992ef8f9ec80f54dae56ace3 Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Sat, 1 Aug 2026 16:00:54 +0200 Subject: [PATCH] Replace apollo with tanstack query --- package-lock.json | 194 +-------------------- package.json | 2 - src/App.tsx | 24 +-- src/api.test.ts | 39 ++++- src/api.ts | 16 +- src/modules/membership/Membership.test.tsx | 49 +----- src/modules/membership/Proof.test.tsx | 57 ++---- src/modules/membership/useProof.tsx | 21 +-- src/queries/queryKeys.ts | 1 + src/queries/useMembershipTypes.ts | 13 ++ src/testUtils.tsx | 39 +++-- src/types/data.ts | 6 + 12 files changed, 135 insertions(+), 326 deletions(-) create mode 100644 src/queries/useMembershipTypes.ts diff --git a/package-lock.json b/package-lock.json index 61e9daf..af607b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "cnapp", "version": "2.0.1", "dependencies": { - "@apollo/client": "^3.14.1", "@date-fns/tz": "^1.5.0", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-vector-icons/feather": "^13.1.2", @@ -31,7 +30,6 @@ "expo-notifications": "~57.0.8", "expo-splash-screen": "~57.0.5", "expo-status-bar": "~57.0.1", - "graphql": "^16.9.0", "react": "19.2.3", "react-hook-form": "^7.52.1", "react-native": "0.86.2", @@ -60,48 +58,6 @@ "typescript": "~6.0.3" } }, - "node_modules/@apollo/client": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.14.1.tgz", - "integrity": "sha512-SgGX6E23JsZhUdG2anxiyHvEvvN6CUaI4ZfMsndZFeuHPXL3H0IsaiNAhLITSISbeyeYd+CBd9oERXQDdjXWZw==", - "license": "MIT", - "dependencies": { - "@graphql-typed-document-node/core": "^3.1.1", - "@wry/caches": "^1.0.0", - "@wry/equality": "^0.5.6", - "@wry/trie": "^0.5.0", - "graphql-tag": "^2.12.6", - "hoist-non-react-statics": "^3.3.2", - "optimism": "^0.18.0", - "prop-types": "^15.7.2", - "rehackt": "^0.1.0", - "symbol-observable": "^4.0.0", - "ts-invariant": "^0.10.3", - "tslib": "^2.3.0", - "zen-observable-ts": "^1.2.5" - }, - "peerDependencies": { - "graphql": "^15.0.0 || ^16.0.0", - "graphql-ws": "^5.5.5 || ^6.0.3", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc", - "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" - }, - "peerDependenciesMeta": { - "graphql-ws": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "subscriptions-transport-ws": { - "optional": true - } - } - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -2076,15 +2032,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/@graphql-typed-document-node/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", - "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", - "license": "MIT", - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -4912,54 +4859,6 @@ "integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==", "license": "BSD-3-Clause" }, - "node_modules/@wry/caches": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", - "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@wry/context": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", - "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@wry/equality": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", - "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@wry/trie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", - "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -8899,30 +8798,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/graphql": { - "version": "16.14.2", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", - "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, - "node_modules/graphql-tag": { - "version": "2.12.7", - "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz", - "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==", - "license": "MIT", - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -12488,6 +12363,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12669,18 +12545,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/optimism": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz", - "integrity": "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==", - "license": "MIT", - "dependencies": { - "@wry/caches": "^1.0.0", - "@wry/context": "^0.7.0", - "@wry/trie": "^0.5.0", - "tslib": "^2.3.0" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -13264,6 +13128,7 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -13275,6 +13140,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, "license": "MIT" }, "node_modules/proxy-from-env": { @@ -13937,24 +13803,6 @@ "regjsparser": "bin/parser" } }, - "node_modules/rehackt": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/rehackt/-/rehackt-0.1.0.tgz", - "integrity": "sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "*" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -15015,15 +14863,6 @@ "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", "license": "CC0-1.0" }, - "node_modules/symbol-observable": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", - "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -15260,18 +15099,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-invariant": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.10.3.tgz", - "integrity": "sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -16112,21 +15939,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zen-observable": { - "version": "0.8.15", - "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", - "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==", - "license": "MIT" - }, - "node_modules/zen-observable-ts": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.2.5.tgz", - "integrity": "sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==", - "license": "MIT", - "dependencies": { - "zen-observable": "0.8.15" - } - }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index 390d5f2..82b3ac4 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "web": "expo start --web" }, "dependencies": { - "@apollo/client": "^3.14.1", "@date-fns/tz": "^1.5.0", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-vector-icons/feather": "^13.1.2", @@ -38,7 +37,6 @@ "expo-notifications": "~57.0.8", "expo-splash-screen": "~57.0.5", "expo-status-bar": "~57.0.1", - "graphql": "^16.9.0", "react": "19.2.3", "react-hook-form": "^7.52.1", "react-native": "0.86.2", diff --git a/src/App.tsx b/src/App.tsx index d65aaaa..7ce7b45 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { - - {/* FIXME: Read urlScheme and merchantIdentifier from .Config */} - - - - + {/* FIXME: Read urlScheme and merchantIdentifier from .Config */} + + + diff --git a/src/api.test.ts b/src/api.test.ts index df426b1..2948f1d 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -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' }; diff --git a/src/api.ts b/src/api.ts index 69a9dbe..c2eb9c5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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 { return events; } +export async function fetchMembershipTypes(): Promise { + 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/`, { diff --git a/src/modules/membership/Membership.test.tsx b/src/modules/membership/Membership.test.tsx index ceec0ff..1570e11 100644 --- a/src/modules/membership/Membership.test.tsx +++ b/src/modules/membership/Membership.test.tsx @@ -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(); @@ -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(, { 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(, { 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(, { 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(, { 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(, { 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(, { initialToken: 'test-token', initialUser: testUser, - apolloMocks, }); fireEvent.press(await findByText('Logg ut')); diff --git a/src/modules/membership/Proof.test.tsx b/src/modules/membership/Proof.test.tsx index bf42f0a..c714bcc 100644 --- a/src/modules/membership/Proof.test.tsx +++ b/src/modules/membership/Proof.test.tsx @@ -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( , { 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)')); diff --git a/src/modules/membership/useProof.tsx b/src/modules/membership/useProof.tsx index 5a5990c..a0cbcf6 100644 --- a/src/modules/membership/useProof.tsx +++ b/src/modules/membership/useProof.tsx @@ -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, diff --git a/src/queries/queryKeys.ts b/src/queries/queryKeys.ts index cadf7c0..4d02887 100644 --- a/src/queries/queryKeys.ts +++ b/src/queries/queryKeys.ts @@ -1,4 +1,5 @@ export const queryKeys = { user: ['user'], events: ['events'], + membershipTypes: ['membershipTypes'], } as const; diff --git a/src/queries/useMembershipTypes.ts b/src/queries/useMembershipTypes.ts new file mode 100644 index 0000000..4e0077b --- /dev/null +++ b/src/queries/useMembershipTypes.ts @@ -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, + }); +} diff --git a/src/testUtils.tsx b/src/testUtils.tsx index e8ac17d..844de34 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -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( - - - - {ui} - - - , + + + {ui} + + , ); return { queryClient, ...result }; diff --git a/src/types/data.ts b/src/types/data.ts index 71ebfa0..ec1b964 100644 --- a/src/types/data.ts +++ b/src/types/data.ts @@ -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;