diff --git a/src/api.test.ts b/src/api.test.ts index 8cc0694..18a5ced 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -1,11 +1,26 @@ import { applyMiddleware, legacy_createStore as createStore } from 'redux'; import { thunk } from 'redux-thunk'; -import { requestLogin, requestUserData } from '@/api'; +import { + requestDeleteUser, + requestLogin, + requestRegisterUser, + requestStripePaymentSheet, + requestUserData, +} from '@/api'; import rootReducer from '@/reducers'; const createTestStore = () => createStore(rootReducer, undefined, applyMiddleware(thunk)); +const fetchMock = global.fetch as jest.Mock; + +const mockResponseOnce = (json: unknown, ok = true) => + fetchMock.mockResolvedValueOnce({ ok, status: ok ? 200 : 400, json: async () => json }); + +beforeEach(() => { + fetchMock.mockClear(); +}); + describe('dev fake users (__DEV__ only)', () => { it.each([ ['nonmember', false], @@ -22,3 +37,187 @@ describe('dev fake users (__DEV__ only)', () => { expect(global.fetch).not.toHaveBeenCalled(); }); }); + +describe('requestLogin', () => { + it('posts credentials and stores the token on success', async () => { + const store = createTestStore(); + mockResponseOnce({ token: 'token-123' }); + + await store.dispatch(requestLogin('grisen', 'hunter2')); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://dusken.example.test/auth/obtain-token/', + expect.objectContaining({ + method: 'POST', + 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(); + const apiError = { non_field_errors: ['Kan ikke logge inn med disse opplysningene'] }; + mockResponseOnce(apiError, false); + + await store.dispatch(requestLogin('grisen', 'wrong-password')); + + expect(store.getState()).toMatchObject({ + isAuthenticated: false, + isLoggingIn: false, + userToken: null, + loginError: apiError, + }); + }); + + it('falls back to a generic error message when the API returns an empty body', async () => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + const store = createTestStore(); + mockResponseOnce(null, false); + + await store.dispatch(requestLogin('grisen', 'hunter2')); + + expect(store.getState().loginError).toEqual({ non_field_errors: ['Kunne ikke logge inn, prøv igjen'] }); + }); +}); + +describe('requestUserData', () => { + it('fetches /api/me/ with the auth token and stores the user', async () => { + const store = createTestStore(); + const user = { username: 'grisen', email: 'grisen@example.test', is_member: true }; + mockResponseOnce(user); + + await store.dispatch(requestUserData('token-123')); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://dusken.example.test/api/me/', + expect.objectContaining({ + method: 'GET', + 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); + + await store.dispatch(requestUserData('expired-token')); + + expect(store.getState()).toMatchObject({ isFetchingUserData: false, user: null, userError: apiError }); + }); +}); + +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(); + mockResponseOnce({ username: 'grisen', email: 'grisen@example.test', auth_token: 'token-456' }); + + await store.dispatch(requestRegisterUser(formValues)); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://dusken.example.test/api/user/register/', + expect.objectContaining({ method: 'POST', body: JSON.stringify(formValues) }), + ); + 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(); + const apiError = { email: ['Denne e-postadressen er allerede i bruk'] }; + mockResponseOnce(apiError, false); + + await store.dispatch(requestRegisterUser(formValues)); + + expect(store.getState()).toMatchObject({ + isRegisteringUser: false, + isAuthenticated: false, + registerError: 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); + + mockResponseOnce({}); + await store.dispatch(requestDeleteUser('token-123')); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://dusken.example.test/api/me/delete/', + expect.objectContaining({ + method: 'DELETE', + 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); + + await store.dispatch(requestDeleteUser('token-123')); + + expect(store.getState()).toMatchObject({ isDeletingUser: false, deleteUserError: apiError }); + }); +}); + +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' }; + mockResponseOnce(sheet); + + await expect(requestStripePaymentSheet('token-123', 'standard')).resolves.toEqual(sheet); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://dusken.example.test/api/stripe/payment-sheet/', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ membership_type: 'standard' }), + headers: expect.objectContaining({ Authorization: 'Token token-123' }), + }), + ); + }); + + it('rejects with the API detail message on failure', async () => { + mockResponseOnce({ detail: 'Ugyldig medlemskapstype' }, false); + + await expect(requestStripePaymentSheet('token-123', 'bogus')).rejects.toThrow('Ugyldig medlemskapstype'); + }); + + it('rejects with a generic message when the API gives no detail', async () => { + mockResponseOnce({}, false); + + await expect(requestStripePaymentSheet('token-123', 'standard')).rejects.toThrow( + 'Kunne ikke belaste bankkortet, prøv igjen', + ); + }); +}); diff --git a/src/api.ts b/src/api.ts index e2c89cf..e382da1 100644 --- a/src/api.ts +++ b/src/api.ts @@ -212,7 +212,7 @@ export async function requestStripePaymentSheet(authToken: string, membershipTyp return data; }, (data) => { - return data.detail || 'Kunne ikke belaste bankkortet, prøv igjen'; + throw new Error(data?.detail || 'Kunne ikke belaste bankkortet, prøv igjen'); }, ); } diff --git a/src/components/FormInput.tsx b/src/components/FormInput.tsx index af236bf..4f7adf5 100644 --- a/src/components/FormInput.tsx +++ b/src/components/FormInput.tsx @@ -36,6 +36,7 @@ const FormInput = ({ {label}} + accessibilityLabel={label} returnKeyType={returnKeyType} onChangeText={onChange} onBlur={onBlur} diff --git a/src/modules/membership/Proof.test.tsx b/src/modules/membership/Proof.test.tsx new file mode 100644 index 0000000..34a5978 --- /dev/null +++ b/src/modules/membership/Proof.test.tsx @@ -0,0 +1,180 @@ +import { gql } from '@apollo/client'; +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 type { UserType } from '@/types/data'; + +// The setup-wide stripe mock resolves initPaymentSheet/presentPaymentSheet +// with { error: null }; useStripe() hands out these same jest.fn instances. +const { initPaymentSheet, presentPaymentSheet } = jest.requireMock('@stripe/stripe-react-native') as { + initPaymentSheet: jest.Mock; + presentPaymentSheet: jest.Mock; +}; + +const fetchMock = global.fetch as jest.Mock; + +const mockPaymentSheetResponse = () => + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + 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 = { + ...testUser, + is_member: false, + last_membership: { membership_type: 'standard', end_date: '2020-01-01' }, +}; + +const renderProof = ({ user = nonMember, fetchUser = jest.fn() }: { user?: UserType; fetchUser?: () => void } = {}) => + renderWithProviders( + , + { + preloadedState: { isAuthenticated: true, userToken: 'test-token', user }, + apolloMocks, + }, + ); + +beforeEach(() => { + jest.clearAllMocks(); + (Notifications.getPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'undetermined' }); +}); + +it('purchases the preselected membership through the stripe payment sheet', async () => { + mockPaymentSheetResponse(); + const { findByText } = renderProof(); + + // 'Ordinær' (slug: regular) is preselected; the button enables once the + // membership type query has resolved and its price is shown + fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)')); + + expect(await findByText('Your order is confirmed!')).toBeTruthy(); + expect(fetchMock).toHaveBeenCalledWith( + 'https://dusken.example.test/api/stripe/payment-sheet/', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ membership_type: 'regular' }), + headers: expect.objectContaining({ Authorization: 'Token test-token' }), + }), + ); + expect(initPaymentSheet).toHaveBeenCalledWith( + expect.objectContaining({ + paymentIntentClientSecret: 'pi_secret', + customerEphemeralKeySecret: 'ek_secret', + customerId: 'cus_123', + defaultBillingDetails: expect.objectContaining({ + email: testUser.email, + name: 'Test Testesen', + }), + }), + ); + expect(presentPaymentSheet).toHaveBeenCalled(); +}); + +it('purchases the selected membership type, not the default', async () => { + mockPaymentSheetResponse(); + const { findByText, getByText } = renderProof(); + + fireEvent.press(await findByText('Student')); + fireEvent.press(getByText('Kjøp medlemskap (150 NOK)')); + + expect(await findByText('Your order is confirmed!')).toBeTruthy(); + expect(fetchMock).toHaveBeenCalledWith( + 'https://dusken.example.test/api/stripe/payment-sheet/', + expect.objectContaining({ body: JSON.stringify({ membership_type: 'student' }) }), + ); +}); + +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(); + + fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)')); + + expect(await findByText('Error code: Canceled The payment flow has been canceled')).toBeTruthy(); + expect(queryByText('Your order is confirmed!')).toBeNull(); +}); + +it('shows an error and does not present the sheet when the backend rejects the purchase', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ detail: 'Ugyldig medlemskapstype' }), + }); + const { findByText, getByRole } = renderProof(); + + fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)')); + + expect(await findByText('Oops! Could not initialise payment')).toBeTruthy(); + expect(initPaymentSheet).not.toHaveBeenCalled(); + expect(presentPaymentSheet).not.toHaveBeenCalled(); + // The purchase button recovers so the user can retry + expect(getByRole('button', { name: 'Kjøp medlemskap (300 NOK)' })).toBeEnabled(); +}); + +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(); + + fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)')); + + expect(await findByText('Oops! Could not initialise payment')).toBeTruthy(); + expect(presentPaymentSheet).not.toHaveBeenCalled(); +}); + +it('refetches user data when the stored membership has expired', async () => { + const fetchUser = jest.fn(); + renderProof({ user: expiredMember, fetchUser }); + + await waitFor(() => expect(fetchUser).toHaveBeenCalled()); + // Notification permission was never granted, so nothing is scheduled + expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); +}); + +it('cancels stale expiry notifications when permission is granted, without scheduling one in the past', async () => { + (Notifications.getPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' }); + const fetchUser = jest.fn(); + renderProof({ user: expiredMember, fetchUser }); + + // Once the granted permission resolves, the expiry effect runs the cleanup + await waitFor(() => expect(Notifications.cancelAllScheduledNotificationsAsync).toHaveBeenCalled()); + expect(fetchUser).toHaveBeenCalled(); + // The expiry date is in the past, so no new notification is scheduled + expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); +}); diff --git a/src/modules/membership/useProof.tsx b/src/modules/membership/useProof.tsx index 3a4d076..b17b173 100644 --- a/src/modules/membership/useProof.tsx +++ b/src/modules/membership/useProof.tsx @@ -38,11 +38,14 @@ const useProof = ({ fetchUser }: UseProofProps) => { )?.price ?? 25000; const openPaymentSheet = async () => { + setPaymentLoading(true); try { await initializePaymentSheet(); } catch (err) { console.error('🔥', err); setToast({ message: 'Oops! Could not initialise payment', visible: true, preset: 'failure' }); + setPaymentLoading(false); + return; } const { error } = await presentPaymentSheet(); const message = error ? `Error code: ${error.code} ${error.message}` : 'Your order is confirmed!'; @@ -51,7 +54,6 @@ const useProof = ({ fetchUser }: UseProofProps) => { }; const initializePaymentSheet = async () => { - setPaymentLoading(true); const { paymentIntent, ephemeralKey, customer } = await requestStripePaymentSheet( userToken as string, membershipTypeSlug, @@ -71,8 +73,8 @@ const useProof = ({ fetchUser }: UseProofProps) => { }, returnURL: `${Constants.expoConfig?.scheme}://stripe-redirect`, }); - if (!error) { - setPaymentLoading(true); + if (error) { + throw error; } }; diff --git a/src/modules/register/UserRegister.test.tsx b/src/modules/register/UserRegister.test.tsx index 4e6e31c..714ec3f 100644 --- a/src/modules/register/UserRegister.test.tsx +++ b/src/modules/register/UserRegister.test.tsx @@ -1,8 +1,40 @@ +import { fireEvent, waitFor, type RenderResult } from '@testing-library/react-native'; import React from 'react'; import UserRegister from '@/modules/register/UserRegister'; import { renderWithProviders } from '@/testUtils'; +// The screen is pushed on top of Login and closes itself with goBack() once +// registration succeeds; stub the navigation object so we can assert on that. +const mockGoBack = jest.fn(); +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { ...actual, useNavigation: () => ({ goBack: mockGoBack }) }; +}); + +const fetchMock = global.fetch as jest.Mock; + +const mockResponseOnce = (json: unknown, ok = true) => + fetchMock.mockResolvedValueOnce({ ok, status: ok ? 200 : 400, json: async () => json }); + +const validFormValues: Record = { + Fornavn: 'Grisen', + Etternavn: 'Testesen', + 'E-post': 'grisen@example.test', + Mobilnummer: '+4712345678', + Passord: 'hunter2', +}; + +const fillForm = (getByLabelText: RenderResult['getByLabelText'], overrides: Record = {}) => { + Object.entries({ ...validFormValues, ...overrides }).forEach(([label, value]) => { + fireEvent.changeText(getByLabelText(label), value); + }); +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + it('renders the registration form', () => { const { getByText } = renderWithProviders(); @@ -10,3 +42,63 @@ it('renders the registration form', () => { expect(getByText('E-post')).toBeTruthy(); expect(getByText('Registrer meg')).toBeTruthy(); }); + +it('disables the submit button while the form is incomplete', () => { + const { getByText, getByRole, store } = renderWithProviders(); + + expect(getByRole('button', { name: 'Registrer meg' })).toBeDisabled(); + fireEvent.press(getByText('Registrer meg')); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(store.getState().isRegisteringUser).toBe(false); +}); + +it('submits the form, authenticates and navigates back on success', async () => { + mockResponseOnce({ username: 'grisen', email: 'grisen@example.test', auth_token: 'token-456' }); + const { getByText, getByRole, getByLabelText, store } = renderWithProviders(); + + fillForm(getByLabelText); + await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled()); + fireEvent.press(getByText('Registrer meg')); + + await waitFor(() => expect(store.getState().isAuthenticated).toBe(true)); + expect(fetchMock).toHaveBeenCalledWith( + 'https://dusken.example.test/api/user/register/', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + first_name: 'Grisen', + last_name: 'Testesen', + email: 'grisen@example.test', + phone_number: '+4712345678', + password: 'hunter2', + }), + }), + ); + expect(store.getState().userToken).toBe('token-456'); + expect(mockGoBack).toHaveBeenCalled(); +}); + +it('shows server field errors on the matching form field', async () => { + mockResponseOnce({ email: ['Denne e-postadressen er allerede i bruk'] }, false); + const { getByText, getByRole, getByLabelText, findByText, store } = renderWithProviders(); + + fillForm(getByLabelText); + await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled()); + fireEvent.press(getByText('Registrer meg')); + + expect(await findByText('Denne e-postadressen er allerede i bruk')).toBeTruthy(); + expect(store.getState().isAuthenticated).toBe(false); + expect(mockGoBack).not.toHaveBeenCalled(); +}); + +it('rejects an invalid e-mail address without hitting the network', async () => { + const { getByLabelText, findByText } = renderWithProviders(); + + fillForm(getByLabelText, { 'E-post': 'not-an-email' }); + // Enter on the last field submits too, bypassing the disabled button + fireEvent(getByLabelText('Passord'), 'submitEditing'); + + expect(await findByText('er ikke en gyldig e-post')).toBeTruthy(); + expect(fetchMock).not.toHaveBeenCalled(); +}); diff --git a/src/modules/register/useUserRegister.tsx b/src/modules/register/useUserRegister.tsx index 91c2ed4..363f268 100644 --- a/src/modules/register/useUserRegister.tsx +++ b/src/modules/register/useUserRegister.tsx @@ -64,8 +64,8 @@ const useUserRegister = () => { }, [isAuthenticatedPrev, isAuthenticated, navigation]); useEffect(() => { - clearErrors(); if (!isSubmitted || !registerError) return; + clearErrors(); Object.keys(registerError).forEach((key) => { const errorMessages = registerError[key]; if (!errorMessages || !Array.isArray(errorMessages)) { diff --git a/src/reduxStore.ts b/src/reduxStore.ts index 0689c62..de6540e 100644 --- a/src/reduxStore.ts +++ b/src/reduxStore.ts @@ -9,6 +9,10 @@ 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);