Improve test coverage and fix various issues

This commit is contained in:
ponas committed 2026-07-30 00:02:21 +02:00
1 parent e812ab2b45
commit 1069d66e2c
8 files changed
+484 -6

No files matched your search

+180
View File
@@ -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(
<Proof onLogoutPress={jest.fn()} fetchUser={fetchUser} isFetchingUserData={false} onDeletePress={jest.fn()} />,
{
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();
});
+5 -3
View File
@@ -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;
}
};
@@ -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<string, string> = {
Fornavn: 'Grisen',
Etternavn: 'Testesen',
'E-post': 'grisen@example.test',
Mobilnummer: '+4712345678',
Passord: 'hunter2',
};
const fillForm = (getByLabelText: RenderResult['getByLabelText'], overrides: Record<string, string> = {}) => {
Object.entries({ ...validFormValues, ...overrides }).forEach(([label, value]) => {
fireEvent.changeText(getByLabelText(label), value);
});
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the registration form', () => {
const { getByText } = renderWithProviders(<UserRegister />);
@@ -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(<UserRegister />);
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(<UserRegister />);
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(<UserRegister />);
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(<UserRegister />);
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();
});
+1 -1
View File
@@ -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)) {