This commit is contained in:
19 files changed
+203
-192
No files matched your search
@@ -29,6 +29,12 @@ jest.mock('@react-native-async-storage/async-storage', () =>
|
||||
|
||||
jest.mock('@stripe/stripe-react-native', () => require('@stripe/stripe-react-native/jest/mock.js'));
|
||||
|
||||
// The preset's RefreshControl mock drops its props, so onRefresh and testID never reach the host tree
|
||||
jest.mock('react-native/Libraries/Components/RefreshControl/RefreshControl', () => {
|
||||
const React = require('react');
|
||||
return { __esModule: true, default: (props) => React.createElement('RCTRefreshControl', props) };
|
||||
});
|
||||
|
||||
// Reanimated (even its mock) initializes the native side of worklets at import
|
||||
// time; substitute the mock that ships with react-native-worklets.
|
||||
jest.mock('react-native-worklets', () => require('react-native-worklets/src/mock'));
|
||||
|
||||
Generated
+51
-11
@@ -44,7 +44,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.9",
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@testing-library/react-native": "^14.0.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/react": "~19.2.10",
|
||||
"eslint": "^9.39.5",
|
||||
@@ -54,7 +54,7 @@
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "~57.0.5",
|
||||
"prettier": "^3.9.6",
|
||||
"react-test-renderer": "19.2.3",
|
||||
"test-renderer": "1.2",
|
||||
"typescript": "~6.0.3"
|
||||
}
|
||||
},
|
||||
@@ -4551,25 +4551,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/react-native": {
|
||||
"version": "13.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz",
|
||||
"integrity": "sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==",
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-14.0.1.tgz",
|
||||
"integrity": "sha512-2r2e2y8SkUNRWKRXlUGqDYunB+AkbdXUPn49Bs5rpv9oxRb0K3USBVugbPbAKJjfj4w5Ba91NJ8LcQCZyopUZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jest-matcher-utils": "^30.0.5",
|
||||
"jest-matcher-utils": "^30.4.1",
|
||||
"picocolors": "^1.1.1",
|
||||
"pretty-format": "^30.0.5",
|
||||
"pretty-format": "^30.4.1",
|
||||
"redent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": "^22.13.0 || >=24"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"jest": ">=29.0.0",
|
||||
"react": ">=18.2.0",
|
||||
"react-native": ">=0.71",
|
||||
"react-test-renderer": ">=18.2.0"
|
||||
"react": ">=19.0.0",
|
||||
"react-native": ">=0.78",
|
||||
"test-renderer": "^1.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"jest": {
|
||||
@@ -4780,6 +4780,16 @@
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-reconciler": {
|
||||
"version": "0.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.33.0.tgz",
|
||||
"integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-test-renderer": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
|
||||
@@ -15657,6 +15667,36 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/test-renderer": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/test-renderer/-/test-renderer-1.2.0.tgz",
|
||||
"integrity": "sha512-JYiEGbgBGtmHAWX8Kf99gGRL1HtjcRVHLrmJNTZL4vFs9XrnWcuL45Iszw/pO4094+JR7havSrN+ds6YTOpSQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react-reconciler": "~0.33.0",
|
||||
"react-reconciler": "~0.33.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/test-renderer/node_modules/react-reconciler": {
|
||||
"version": "0.33.0",
|
||||
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz",
|
||||
"integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/throat": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz",
|
||||
|
||||
+2
-2
@@ -51,7 +51,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.9",
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@testing-library/react-native": "^14.0.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/react": "~19.2.10",
|
||||
"eslint": "^9.39.5",
|
||||
@@ -61,7 +61,7 @@
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "~57.0.5",
|
||||
"prettier": "^3.9.6",
|
||||
"react-test-renderer": "19.2.3",
|
||||
"test-renderer": "~1.2.0",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"expo": {
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ jest.mock('@stripe/stripe-react-native', () => ({
|
||||
afterEach(() => appQueryClient.clear());
|
||||
|
||||
it('renders the whole app up to the navigation tabs', async () => {
|
||||
const { findByText } = render(<App />);
|
||||
const { findByText } = await render(<App />);
|
||||
|
||||
// A tab label proves the auth bootstrap finished and Navigation mounted
|
||||
expect(await findByText('Medlemskap')).toBeTruthy();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { act, renderHook, waitFor } from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
|
||||
import { AuthProvider, useAuth } from '@/auth/AuthContext';
|
||||
import * as tokenStorage from '@/auth/tokenStorage';
|
||||
import { queryKeys } from '@/queries/queryKeys';
|
||||
|
||||
const TOKEN_KEY = 'cnapp.authToken';
|
||||
@@ -13,7 +14,7 @@ const grisen = { username: 'grisen', email: 'grisen@example.test', is_member: tr
|
||||
// gcTime: Infinity so no GC timers keep Jest alive
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { gcTime: Infinity } } });
|
||||
|
||||
const renderAuth = ({
|
||||
const renderAuth = async ({
|
||||
initialToken,
|
||||
queryClient = createQueryClient(),
|
||||
}: { initialToken?: string | null; queryClient?: QueryClient } = {}) => {
|
||||
@@ -22,7 +23,7 @@ const renderAuth = ({
|
||||
<AuthProvider initialToken={initialToken}>{children}</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return { queryClient, ...renderHook(() => useAuth(), { wrapper }) };
|
||||
return { queryClient, ...(await renderHook(() => useAuth(), { wrapper })) };
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -32,19 +33,25 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
it('renders nothing until the stored session is restored', async () => {
|
||||
await AsyncStorage.setItem(TOKEN_KEY, 'token-123');
|
||||
// Hold the token read so the closed gate is still observable after the async render
|
||||
let releaseToken!: (token: string | null) => void;
|
||||
jest
|
||||
.spyOn(tokenStorage, 'getStoredToken')
|
||||
.mockImplementationOnce(() => new Promise((resolve) => (releaseToken = resolve)));
|
||||
|
||||
const { result } = renderAuth();
|
||||
const { result } = await renderAuth();
|
||||
// The gate: children (and thus the hook) have not rendered yet
|
||||
expect(result.current).toBeNull();
|
||||
|
||||
await waitFor(() => expect(result.current).not.toBeNull());
|
||||
await act(async () => {
|
||||
releaseToken('token-123');
|
||||
});
|
||||
expect(result.current.token).toBe('token-123');
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('starts logged out when nothing is stored', async () => {
|
||||
const { result } = renderAuth();
|
||||
const { result } = await renderAuth();
|
||||
|
||||
await waitFor(() => expect(result.current).not.toBeNull());
|
||||
expect(result.current.token).toBeNull();
|
||||
@@ -57,7 +64,7 @@ it('runs the redux-persist migration and seeds the cached user', async () => {
|
||||
JSON.stringify({ userToken: JSON.stringify('9a8b'), user: JSON.stringify(grisen) }),
|
||||
);
|
||||
|
||||
const { result, queryClient } = renderAuth();
|
||||
const { result, queryClient } = await renderAuth();
|
||||
|
||||
await waitFor(() => expect(result.current?.token).toBe('9a8b'));
|
||||
expect(queryClient.getQueryData(queryKeys.user)).toEqual(grisen);
|
||||
@@ -65,9 +72,9 @@ it('runs the redux-persist migration and seeds the cached user', async () => {
|
||||
});
|
||||
|
||||
it('signIn stores the token and flips isAuthenticated', async () => {
|
||||
const { result } = renderAuth({ initialToken: null });
|
||||
const { result } = await renderAuth({ initialToken: null });
|
||||
|
||||
act(() => result.current.signIn('token-1'));
|
||||
await act(() => result.current.signIn('token-1'));
|
||||
|
||||
expect(result.current.token).toBe('token-1');
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
@@ -79,8 +86,8 @@ it('signOut clears the token and drops the cached user', async () => {
|
||||
queryClient.setQueryData(queryKeys.user, grisen);
|
||||
await AsyncStorage.setItem(TOKEN_KEY, 'token-1');
|
||||
|
||||
const { result } = renderAuth({ initialToken: 'token-1', queryClient });
|
||||
act(() => result.current.signOut());
|
||||
const { result } = await renderAuth({ initialToken: 'token-1', queryClient });
|
||||
await act(() => result.current.signOut());
|
||||
|
||||
expect(result.current.token).toBeNull();
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
@@ -88,10 +95,10 @@ it('signOut clears the token and drops the cached user', async () => {
|
||||
await waitFor(async () => expect(await AsyncStorage.getItem(TOKEN_KEY)).toBeNull());
|
||||
});
|
||||
|
||||
it('skips the storage bootstrap when given an initialToken', () => {
|
||||
it('skips the storage bootstrap when given an initialToken', async () => {
|
||||
const getItemSpy = jest.spyOn(AsyncStorage, 'getItem');
|
||||
|
||||
const { result } = renderAuth({ initialToken: 'token-x' });
|
||||
const { result } = await renderAuth({ initialToken: 'token-x' });
|
||||
|
||||
// Synchronously ready: no gate, no storage reads
|
||||
expect(result.current.token).toBe('token-x');
|
||||
|
||||
@@ -3,8 +3,8 @@ import React from 'react';
|
||||
import About from '@/modules/about/About';
|
||||
import { renderWithProviders } from '@/testUtils';
|
||||
|
||||
it('renders the about screen', () => {
|
||||
const { getByText } = renderWithProviders(<About />);
|
||||
it('renders the about screen', async () => {
|
||||
const { getByText } = await renderWithProviders(<About />);
|
||||
|
||||
expect(getByText('Åpningstider')).toBeTruthy();
|
||||
expect(getByText('Ta kontakt')).toBeTruthy();
|
||||
|
||||
@@ -47,7 +47,7 @@ const event = {
|
||||
it('formats the next occurrence as a Norwegian date with the venue', async () => {
|
||||
mockFetch.mockResolvedValue({ json: async () => ({ events: [event] }) });
|
||||
|
||||
const { findByText } = renderWithProviders(<EventList />);
|
||||
const { findByText } = await renderWithProviders(<EventList />);
|
||||
|
||||
expect(await findByText('lø. 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy();
|
||||
});
|
||||
@@ -59,7 +59,7 @@ it('formats weekday and month abbreviations across the year', async () => {
|
||||
];
|
||||
mockFetch.mockResolvedValue({ json: async () => ({ events }) });
|
||||
|
||||
const { findByText } = renderWithProviders(<EventList />);
|
||||
const { findByText } = await renderWithProviders(<EventList />);
|
||||
|
||||
expect(await findByText('on. 30. des. 2026 kl. 18:00 i Storsalen')).toBeTruthy();
|
||||
expect(await findByText('ma. 17. mai 2027 kl. 13:00 i Storsalen')).toBeTruthy();
|
||||
@@ -70,7 +70,7 @@ it('renders venue-local (Oslo) time whatever the API offset and device timezone'
|
||||
const events = [{ ...event, nextOccurrence: { ...event.nextOccurrence, start: '2026-08-01T17:00:00Z' } }];
|
||||
mockFetch.mockResolvedValue({ json: async () => ({ events }) });
|
||||
|
||||
const { findByText } = renderWithProviders(<EventList />);
|
||||
const { findByText } = await renderWithProviders(<EventList />);
|
||||
|
||||
expect(await findByText('lø. 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy();
|
||||
});
|
||||
@@ -78,7 +78,7 @@ it('renders venue-local (Oslo) time whatever the API offset and device timezone'
|
||||
it('prefixes the time with Neste: when the event has more occurrences', async () => {
|
||||
mockFetch.mockResolvedValue({ json: async () => ({ events: [{ ...event, futureOccurrencesCount: 3 }] }) });
|
||||
|
||||
const { findByText } = renderWithProviders(<EventList />);
|
||||
const { findByText } = await renderWithProviders(<EventList />);
|
||||
|
||||
expect(await findByText('Neste: lø. 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy();
|
||||
expect(await findByText('Se flere tider (2)')).toBeTruthy();
|
||||
@@ -87,7 +87,7 @@ it('prefixes the time with Neste: when the event has more occurrences', async ()
|
||||
it('renders events from the API', async () => {
|
||||
mockFetch.mockResolvedValue({ json: async () => ({ events: [event] }) });
|
||||
|
||||
const { findByText } = renderWithProviders(<EventList />);
|
||||
const { findByText } = await renderWithProviders(<EventList />);
|
||||
|
||||
expect(await findByText('Lørdagsquiz')).toBeTruthy();
|
||||
});
|
||||
@@ -95,7 +95,7 @@ it('renders events from the API', async () => {
|
||||
it('renders an error state when the API is unreachable', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('network down'));
|
||||
|
||||
const { findByText } = renderWithProviders(<EventList />);
|
||||
const { findByText } = await renderWithProviders(<EventList />);
|
||||
|
||||
expect(await findByText('Kunne ikke hente programmet...')).toBeTruthy();
|
||||
});
|
||||
@@ -106,11 +106,11 @@ it('stops the refresh spinner and shows the error when refresh fails', async ()
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useEventList(), { wrapper });
|
||||
const { result } = await renderHook(() => useEventList(), { wrapper });
|
||||
await waitFor(() => expect(result.current.events).toHaveLength(1));
|
||||
|
||||
mockFetch.mockRejectedValue(new Error('network down'));
|
||||
act(() => result.current.handleRefresh());
|
||||
await act(() => result.current.handleRefresh());
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBeTruthy());
|
||||
expect(result.current.refreshing).toBe(false);
|
||||
|
||||
@@ -15,20 +15,20 @@ jest.mock('@react-navigation/native', () => {
|
||||
return { ...actual, useNavigation: () => ({ goBack: mockGoBack }) };
|
||||
});
|
||||
|
||||
const fillForm = (
|
||||
const fillForm = async (
|
||||
getByLabelText: RenderResult['getByLabelText'],
|
||||
{ username = 'grisen@example.test', password = 'hunter2' } = {},
|
||||
) => {
|
||||
fireEvent.changeText(getByLabelText('E-post / Brukernavn'), username);
|
||||
fireEvent.changeText(getByLabelText('Passord'), password);
|
||||
await fireEvent.changeText(getByLabelText('E-post / Brukernavn'), username);
|
||||
await fireEvent.changeText(getByLabelText('Passord'), password);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the login form', () => {
|
||||
const { getByText } = renderWithProviders(<Login />);
|
||||
it('renders the login form', async () => {
|
||||
const { getByText } = await renderWithProviders(<Login />);
|
||||
|
||||
expect(getByText('E-post / Brukernavn')).toBeTruthy();
|
||||
expect(getByText('Passord')).toBeTruthy();
|
||||
@@ -37,18 +37,18 @@ it('renders the login form', () => {
|
||||
});
|
||||
|
||||
it('moves focus to the password field when the username is submitted from the keyboard', async () => {
|
||||
const { getByLabelText } = renderWithProviders(<Login />);
|
||||
const { getByLabelText } = await renderWithProviders(<Login />);
|
||||
|
||||
fireEvent(getByLabelText('E-post / Brukernavn'), 'submitEditing');
|
||||
await fireEvent(getByLabelText('E-post / Brukernavn'), 'submitEditing');
|
||||
|
||||
// react-hook-form's setFocus defers the focus call to the next tick
|
||||
await waitFor(() => expect(focusedFieldLabel()).toBe('Passord'));
|
||||
});
|
||||
|
||||
it('opens the forgot password page in the browser', () => {
|
||||
const { getByText } = renderWithProviders(<Login />);
|
||||
it('opens the forgot password page in the browser', async () => {
|
||||
const { getByText } = await renderWithProviders(<Login />);
|
||||
|
||||
fireEvent.press(getByText('Glemt passord?'));
|
||||
await fireEvent.press(getByText('Glemt passord?'));
|
||||
|
||||
// Not one of our own domains in the test env, so no UTM tagging (covered in utils.test.ts)
|
||||
expect(Linking.openURL).toHaveBeenCalledWith('https://dusken.example.test/auth/password_reset/');
|
||||
@@ -56,10 +56,10 @@ it('opens the forgot password page in the browser', () => {
|
||||
|
||||
it('logs in against the API and returns to the previous screen', async () => {
|
||||
const login = jest.spyOn(api, 'login').mockResolvedValue({ token: 'token-123' });
|
||||
const { getByText, getByLabelText } = renderWithProviders(<Login />);
|
||||
const { getByText, getByLabelText } = await renderWithProviders(<Login />);
|
||||
|
||||
fillForm(getByLabelText);
|
||||
fireEvent.press(getByText('Logg inn'));
|
||||
await fillForm(getByLabelText);
|
||||
await fireEvent.press(getByText('Logg inn'));
|
||||
|
||||
await waitFor(() => expect(mockGoBack).toHaveBeenCalled());
|
||||
expect(login).toHaveBeenCalledWith('grisen@example.test', 'hunter2');
|
||||
@@ -72,10 +72,10 @@ it('shows the server error when the credentials are rejected', async () => {
|
||||
400,
|
||||
);
|
||||
jest.spyOn(api, 'login').mockRejectedValueOnce(invalidCredentials);
|
||||
const { getByText, getByLabelText, findByText } = renderWithProviders(<Login />);
|
||||
const { getByText, getByLabelText, findByText } = await renderWithProviders(<Login />);
|
||||
|
||||
fillForm(getByLabelText);
|
||||
fireEvent.press(getByText('Logg inn'));
|
||||
await fillForm(getByLabelText);
|
||||
await fireEvent.press(getByText('Logg inn'));
|
||||
|
||||
expect(await findByText('Kan ikke logge inn med angitt brukernavn og passord')).toBeTruthy();
|
||||
expect(mockGoBack).not.toHaveBeenCalled();
|
||||
@@ -84,20 +84,20 @@ it('shows the server error when the credentials are rejected', async () => {
|
||||
it('shows a generic message when the request fails on the network', async () => {
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(api, 'login').mockRejectedValueOnce(new Error('network down'));
|
||||
const { getByText, getByLabelText, findByText } = renderWithProviders(<Login />);
|
||||
const { getByText, getByLabelText, findByText } = await renderWithProviders(<Login />);
|
||||
|
||||
fillForm(getByLabelText);
|
||||
fireEvent.press(getByText('Logg inn'));
|
||||
await fillForm(getByLabelText);
|
||||
await fireEvent.press(getByText('Logg inn'));
|
||||
|
||||
expect(await findByText('Kunne ikke sende inn skjemaet! Prøv igjen senere...')).toBeTruthy();
|
||||
expect(mockGoBack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs in a dev-fake user without hitting the network', async () => {
|
||||
const { getByText, getByLabelText } = renderWithProviders(<Login />);
|
||||
const { getByText, getByLabelText } = await renderWithProviders(<Login />);
|
||||
|
||||
fillForm(getByLabelText, { username: 'member', password: 'whatever' });
|
||||
fireEvent.press(getByText('Logg inn'));
|
||||
await fillForm(getByLabelText, { username: 'member', password: 'whatever' });
|
||||
await fireEvent.press(getByText('Logg inn'));
|
||||
|
||||
await waitFor(() => expect(mockGoBack).toHaveBeenCalled());
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
@@ -44,7 +44,9 @@ const Login = () => {
|
||||
<Text style={theme.buttonText}>Logg inn</Text>
|
||||
</Button>
|
||||
{loginError && isSubmitted && <FormErrors errors={loginError} />}
|
||||
{isLoggingIn && <ActivityIndicator color={theme.colors.neufOrange} />}
|
||||
{isLoggingIn && (
|
||||
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.neufOrange} />
|
||||
)}
|
||||
<Button
|
||||
small
|
||||
onPress={() => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { fireEvent } from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
import { RefreshControl } from 'react-native';
|
||||
|
||||
import * as api from '@/api';
|
||||
import { ApiError } from '@/errors';
|
||||
@@ -19,7 +18,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
it('shows the membership pitch when logged out', async () => {
|
||||
const { findByText } = renderWithProviders(<Membership />);
|
||||
const { findByText } = await renderWithProviders(<Membership />);
|
||||
|
||||
expect(await findByText('Bli medlem')).toBeTruthy();
|
||||
expect(await findByText('Logg inn')).toBeTruthy();
|
||||
@@ -28,7 +27,7 @@ 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 };
|
||||
|
||||
const { findByText, getByText } = renderWithProviders(<Membership />, {
|
||||
const { findByText, getByText } = await renderWithProviders(<Membership />, {
|
||||
initialToken: 'test-token',
|
||||
initialUser: nonMember,
|
||||
});
|
||||
@@ -43,15 +42,15 @@ it('lets a logged in non-member pick a membership type and shows its price', asy
|
||||
expect(await findByText('Kjøp medlemskap (300 NOK)')).toBeTruthy();
|
||||
|
||||
// Selecting another type updates the price
|
||||
fireEvent.press(getByText('Student'));
|
||||
await fireEvent.press(getByText('Student'));
|
||||
expect(await findByText('Kjøp medlemskap (150 NOK)')).toBeTruthy();
|
||||
|
||||
fireEvent.press(getByText('Støtte'));
|
||||
await fireEvent.press(getByText('Støtte'));
|
||||
expect(await findByText('Kjøp medlemskap (500 NOK)')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows the membership proof when logged in', async () => {
|
||||
const { findByText } = renderWithProviders(<Membership />, {
|
||||
const { findByText } = await renderWithProviders(<Membership />, {
|
||||
initialToken: 'test-token',
|
||||
initialUser: testUser,
|
||||
});
|
||||
@@ -63,7 +62,7 @@ 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 () => {
|
||||
const fetchUserData = jest.spyOn(api, 'fetchUserData').mockResolvedValue(testUser);
|
||||
|
||||
const { findByText } = renderWithProviders(<Membership />, {
|
||||
const { findByText } = await renderWithProviders(<Membership />, {
|
||||
initialToken: 'test-token',
|
||||
});
|
||||
|
||||
@@ -75,7 +74,7 @@ it('fetches the user with the stored token when only the token survives a cold s
|
||||
it('refetches and renders updated user data on pull-to-refresh', async () => {
|
||||
const nonMember: UserType = { ...testUser, is_member: false, last_membership: null };
|
||||
|
||||
const { findByText, UNSAFE_getByType } = renderWithProviders(<Membership />, {
|
||||
const { findByText, getByTestId } = await renderWithProviders(<Membership />, {
|
||||
initialToken: 'test-token',
|
||||
initialUser: nonMember,
|
||||
});
|
||||
@@ -83,7 +82,7 @@ it('refetches and renders updated user data on pull-to-refresh', async () => {
|
||||
|
||||
// the server now reports an active membership, bought elsewhere
|
||||
const fetchUserData = jest.spyOn(api, 'fetchUserData').mockResolvedValue(testUser);
|
||||
fireEvent(UNSAFE_getByType(RefreshControl), 'refresh');
|
||||
await fireEvent(getByTestId('refresh-control'), 'refresh');
|
||||
|
||||
expect(await findByText('MEDLEM')).toBeTruthy();
|
||||
expect(fetchUserData).toHaveBeenCalledWith('test-token');
|
||||
@@ -92,7 +91,7 @@ it('refetches and renders updated user data on pull-to-refresh', async () => {
|
||||
it('signs out when the stored token has been revoked', async () => {
|
||||
jest.spyOn(api, 'fetchUserData').mockRejectedValueOnce(new ApiError({ detail: 'Ugyldig token.' }, 401));
|
||||
|
||||
const { findByText } = renderWithProviders(<Membership />, {
|
||||
const { findByText } = await renderWithProviders(<Membership />, {
|
||||
initialToken: 'revoked-token',
|
||||
});
|
||||
|
||||
@@ -100,12 +99,12 @@ it('signs out when the stored token has been revoked', async () => {
|
||||
});
|
||||
|
||||
it('logs out and shows the logged-out membership screen again', async () => {
|
||||
const { findByText } = renderWithProviders(<Membership />, {
|
||||
const { findByText } = await renderWithProviders(<Membership />, {
|
||||
initialToken: 'test-token',
|
||||
initialUser: testUser,
|
||||
});
|
||||
|
||||
fireEvent.press(await findByText('Logg ut'));
|
||||
await fireEvent.press(await findByText('Logg ut'));
|
||||
|
||||
expect(await findByText('Bli medlem')).toBeTruthy();
|
||||
expect(mockNavigate).toHaveBeenCalledWith('UserStack', { screen: 'Membership' });
|
||||
|
||||
@@ -25,7 +25,7 @@ const Membership = () => {
|
||||
if (isPending) {
|
||||
return (
|
||||
<View style={theme.card}>
|
||||
<ActivityIndicator color={theme.colors.neufOrange} />
|
||||
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.neufOrange} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, waitFor } from '@testing-library/react-native';
|
||||
import { act, fireEvent, waitFor } from '@testing-library/react-native';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import React from 'react';
|
||||
|
||||
@@ -47,13 +47,20 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
it('disables the purchase button without showing a price while membership types load', async () => {
|
||||
jest.spyOn(api, 'fetchMembershipTypes').mockResolvedValue(testMembershipTypes);
|
||||
const { getByRole, findByText } = renderProof({ membershipTypes: null });
|
||||
// Hold the response so the loading state survives the async render
|
||||
let resolveMembershipTypes!: (types: MembershipTypeOption[]) => void;
|
||||
jest
|
||||
.spyOn(api, 'fetchMembershipTypes')
|
||||
.mockImplementationOnce(() => new Promise((resolve) => (resolveMembershipTypes = resolve)));
|
||||
const { getByRole, findByText } = await renderProof({ membershipTypes: null });
|
||||
|
||||
// The label carries no amount until the query resolves
|
||||
expect(getByRole('button', { name: 'Kjøp medlemskap' })).toBeDisabled();
|
||||
|
||||
// Once the query resolves the real price takes over
|
||||
await act(async () => {
|
||||
resolveMembershipTypes(testMembershipTypes);
|
||||
});
|
||||
expect(await findByText('Kjøp medlemskap (300 NOK)')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -62,24 +69,24 @@ it('shows an error with retry instead of a price when membership types fail to l
|
||||
.spyOn(api, 'fetchMembershipTypes')
|
||||
.mockRejectedValueOnce(new Error('network down'))
|
||||
.mockResolvedValue(testMembershipTypes);
|
||||
const { findByText, getByText, getByRole } = renderProof({ membershipTypes: null });
|
||||
const { findByText, getByText, getByRole } = await renderProof({ membershipTypes: null });
|
||||
|
||||
expect(await findByText('Kunne ikke hente priser')).toBeTruthy();
|
||||
expect(getByRole('button', { name: 'Kjøp medlemskap' })).toBeDisabled();
|
||||
|
||||
// Retrying hits the recovered API and gets the real price
|
||||
fireEvent.press(getByText('Prøv igjen'));
|
||||
await fireEvent.press(getByText('Prøv igjen'));
|
||||
expect(await findByText('Kjøp medlemskap (300 NOK)')).toBeTruthy();
|
||||
expect(getByRole('button', { name: 'Kjøp medlemskap (300 NOK)' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('purchases the preselected membership through the stripe payment sheet', async () => {
|
||||
const requestPaymentSheet = mockPaymentSheet();
|
||||
const { findByText } = renderProof();
|
||||
const { findByText } = await 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)'));
|
||||
await fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
|
||||
|
||||
expect(await findByText('Your order is confirmed!')).toBeTruthy();
|
||||
expect(requestPaymentSheet).toHaveBeenCalledWith('test-token', 'regular');
|
||||
@@ -99,10 +106,10 @@ it('purchases the preselected membership through the stripe payment sheet', asyn
|
||||
|
||||
it('purchases the selected membership type, not the default', async () => {
|
||||
const requestPaymentSheet = mockPaymentSheet();
|
||||
const { findByText } = renderProof();
|
||||
const { findByText } = await renderProof();
|
||||
|
||||
fireEvent.press(await findByText('Student'));
|
||||
fireEvent.press(await findByText('Kjøp medlemskap (150 NOK)'));
|
||||
await fireEvent.press(await findByText('Student'));
|
||||
await fireEvent.press(await findByText('Kjøp medlemskap (150 NOK)'));
|
||||
|
||||
expect(await findByText('Your order is confirmed!')).toBeTruthy();
|
||||
expect(requestPaymentSheet).toHaveBeenCalledWith('test-token', 'student');
|
||||
@@ -113,9 +120,9 @@ it('shows the stripe error when the payment sheet is not completed', async () =>
|
||||
presentPaymentSheet.mockResolvedValueOnce({
|
||||
error: { code: 'Canceled', message: 'The payment flow has been canceled' },
|
||||
});
|
||||
const { findByText, queryByText } = renderProof();
|
||||
const { findByText, queryByText } = await renderProof();
|
||||
|
||||
fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
|
||||
await 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();
|
||||
@@ -124,9 +131,9 @@ 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(() => {});
|
||||
jest.spyOn(api, 'requestStripePaymentSheet').mockRejectedValueOnce(new Error('Ugyldig medlemskapstype'));
|
||||
const { findByText, getByRole } = renderProof();
|
||||
const { findByText, getByRole } = await renderProof();
|
||||
|
||||
fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
|
||||
await fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
|
||||
|
||||
expect(await findByText('Oops! Could not initialise payment')).toBeTruthy();
|
||||
expect(initPaymentSheet).not.toHaveBeenCalled();
|
||||
@@ -139,9 +146,9 @@ it('shows an error and does not present the sheet when stripe fails to initialis
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockPaymentSheet();
|
||||
initPaymentSheet.mockResolvedValueOnce({ error: { code: 'Failed', message: 'Invalid client secret' } });
|
||||
const { findByText } = renderProof();
|
||||
const { findByText } = await renderProof();
|
||||
|
||||
fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
|
||||
await fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)'));
|
||||
|
||||
expect(await findByText('Oops! Could not initialise payment')).toBeTruthy();
|
||||
expect(presentPaymentSheet).not.toHaveBeenCalled();
|
||||
@@ -149,7 +156,7 @@ it('shows an error and does not present the sheet when stripe fails to initialis
|
||||
|
||||
it('refetches user data when the stored membership has expired', async () => {
|
||||
const fetchUser = jest.fn();
|
||||
renderProof({ user: expiredMember, fetchUser });
|
||||
await renderProof({ user: expiredMember, fetchUser });
|
||||
|
||||
await waitFor(() => expect(fetchUser).toHaveBeenCalled());
|
||||
// Notification permission was never granted, so nothing is scheduled
|
||||
@@ -159,7 +166,7 @@ it('refetches user data when the stored membership has expired', async () => {
|
||||
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 });
|
||||
await renderProof({ user: expiredMember, fetchUser });
|
||||
|
||||
// Once the granted permission resolves, the expiry effect runs the cleanup
|
||||
await waitFor(() => expect(Notifications.cancelAllScheduledNotificationsAsync).toHaveBeenCalled());
|
||||
|
||||
@@ -118,7 +118,9 @@ const PurchaseButton = ({
|
||||
<View style={styles.radioForm}>
|
||||
<RadioButton data={options} onSelect={onSelect} defaultValue={defaultValue} />
|
||||
</View>
|
||||
{paymentLoading && <ActivityIndicator color={theme.colors.neufOrange} />}
|
||||
{paymentLoading && (
|
||||
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.neufOrange} />
|
||||
)}
|
||||
<Button
|
||||
onPress={onPurchasePress}
|
||||
fullWidth
|
||||
@@ -317,7 +319,9 @@ const Proof = ({ onLogoutPress, fetchUser, isRefetching, onDeletePress }: ProofP
|
||||
onRainEnd={onRainEnd}
|
||||
/>
|
||||
<View style={styles.container}>
|
||||
<ScrollView refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={fetchUser} />}>
|
||||
<ScrollView
|
||||
refreshControl={<RefreshControl testID="refresh-control" refreshing={isRefetching} onRefresh={fetchUser} />}
|
||||
>
|
||||
<View style={[theme.card, { paddingVertical: 16, backgroundColor: 'transparent' }]}>
|
||||
<MembershipName user={user} />
|
||||
<Logo user={user} />
|
||||
|
||||
@@ -16,8 +16,8 @@ beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the delete confirmation for a logged in user', () => {
|
||||
const { getByText } = renderWithProviders(<UserDelete />, {
|
||||
it('renders the delete confirmation for a logged in user', async () => {
|
||||
const { getByText } = await renderWithProviders(<UserDelete />, {
|
||||
initialToken: 'test-token',
|
||||
initialUser: testUser,
|
||||
});
|
||||
@@ -26,8 +26,8 @@ it('renders the delete confirmation for a logged in user', () => {
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders nothing and navigates back to Membership when not logged in', () => {
|
||||
const { queryByText } = renderWithProviders(<UserDelete />);
|
||||
it('renders nothing and navigates back to Membership when not logged in', async () => {
|
||||
const { queryByText } = await renderWithProviders(<UserDelete />);
|
||||
|
||||
expect(queryByText('Slett meg')).toBeNull();
|
||||
expect(mockNavigate).toHaveBeenCalledWith('UserStack', { screen: 'Membership' });
|
||||
@@ -35,13 +35,13 @@ it('renders nothing and navigates back to Membership when not logged in', () =>
|
||||
|
||||
it('deletes the account and returns to Membership once the username is typed to confirm', async () => {
|
||||
const deleteUser = jest.spyOn(api, 'deleteUser').mockResolvedValueOnce(undefined);
|
||||
const { getByText, getByDisplayValue } = renderWithProviders(<UserDelete />, {
|
||||
const { getByText, getByDisplayValue } = await renderWithProviders(<UserDelete />, {
|
||||
initialToken: 'test-token',
|
||||
initialUser: testUser,
|
||||
});
|
||||
|
||||
fireEvent.changeText(getByDisplayValue(''), 'testbruker');
|
||||
fireEvent.press(getByText('Jeg forstår, slett kontoen min'));
|
||||
await fireEvent.changeText(getByDisplayValue(''), 'testbruker');
|
||||
await fireEvent.press(getByText('Jeg forstår, slett kontoen min'));
|
||||
|
||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('UserStack', { screen: 'Membership' }));
|
||||
expect(deleteUser).toHaveBeenCalledWith('test-token');
|
||||
@@ -51,13 +51,13 @@ it('shows the server error when deletion fails', async () => {
|
||||
jest
|
||||
.spyOn(api, 'deleteUser')
|
||||
.mockRejectedValueOnce(new ApiError({ detail: 'Kunne ikke slette kontoen akkurat nå' }, 400));
|
||||
const { getByText, getByDisplayValue, findByText } = renderWithProviders(<UserDelete />, {
|
||||
const { getByText, getByDisplayValue, findByText } = await renderWithProviders(<UserDelete />, {
|
||||
initialToken: 'test-token',
|
||||
initialUser: testUser,
|
||||
});
|
||||
|
||||
fireEvent.changeText(getByDisplayValue(''), 'testbruker');
|
||||
fireEvent.press(getByText('Jeg forstår, slett kontoen min'));
|
||||
await fireEvent.changeText(getByDisplayValue(''), 'testbruker');
|
||||
await fireEvent.press(getByText('Jeg forstår, slett kontoen min'));
|
||||
|
||||
expect(await findByText('Kunne ikke slette kontoen akkurat nå')).toBeTruthy();
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
|
||||
@@ -56,7 +56,9 @@ const UserDelete = () => {
|
||||
>
|
||||
<Text style={!isDisabled ? styles.whiteText : styles.disabledText}>Jeg forstår, slett kontoen min</Text>
|
||||
</Button>
|
||||
{isDeletingUser && <ActivityIndicator color={theme.colors.neufOrange} />}
|
||||
{isDeletingUser && (
|
||||
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.neufOrange} />
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { act, fireEvent, waitFor, type RenderResult } from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
import { ActivityIndicator } from 'react-native';
|
||||
|
||||
import * as api from '@/api';
|
||||
import { ApiError } from '@/errors';
|
||||
@@ -24,37 +23,33 @@ const validFormValues: Record<string, string> = {
|
||||
Passord: 'hunter2',
|
||||
};
|
||||
|
||||
const fillForm = (getByLabelText: RenderResult['getByLabelText'], overrides: Record<string, string> = {}) => {
|
||||
Object.entries({ ...validFormValues, ...overrides }).forEach(([label, value]) => {
|
||||
fireEvent.changeText(getByLabelText(label), value);
|
||||
});
|
||||
const fillForm = async (getByLabelText: RenderResult['getByLabelText'], overrides: Record<string, string> = {}) => {
|
||||
for (const [label, value] of Object.entries({ ...validFormValues, ...overrides })) {
|
||||
await fireEvent.changeText(getByLabelText(label), value);
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders the registration form', () => {
|
||||
const { getByText } = renderWithProviders(<UserRegister />);
|
||||
it('renders the registration form', async () => {
|
||||
const { getByText } = await renderWithProviders(<UserRegister />);
|
||||
|
||||
expect(getByText('Fornavn')).toBeTruthy();
|
||||
expect(getByText('E-post')).toBeTruthy();
|
||||
expect(getByText('Registrer meg')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('disables the submit button while the form is incomplete', () => {
|
||||
it('disables the submit button while the form is incomplete', async () => {
|
||||
const registerUser = jest.spyOn(api, 'registerUser');
|
||||
const { getByText, getByRole, UNSAFE_queryByType } = renderWithProviders(<UserRegister />);
|
||||
const { getByText, getByRole, queryByRole } = await renderWithProviders(<UserRegister />);
|
||||
|
||||
expect(getByRole('button', { name: 'Registrer meg' })).toBeDisabled();
|
||||
fireEvent.press(getByText('Registrer meg'));
|
||||
await fireEvent.press(getByText('Registrer meg'));
|
||||
|
||||
expect(registerUser).not.toHaveBeenCalled();
|
||||
expect(UNSAFE_queryByType(ActivityIndicator)).toBeNull();
|
||||
expect(queryByRole('progressbar')).toBeNull();
|
||||
});
|
||||
|
||||
it('submits the form, shows a spinner while pending and navigates back on success', async () => {
|
||||
@@ -62,14 +57,14 @@ it('submits the form, shows a spinner while pending and navigates back on succes
|
||||
const registerUser = jest
|
||||
.spyOn(api, 'registerUser')
|
||||
.mockImplementationOnce(() => new Promise((resolve) => (resolveRegister = resolve)));
|
||||
const { getByText, getByRole, getByLabelText, UNSAFE_getByType } = renderWithProviders(<UserRegister />);
|
||||
const { getByText, getByRole, getByLabelText } = await renderWithProviders(<UserRegister />);
|
||||
|
||||
fillForm(getByLabelText);
|
||||
await fillForm(getByLabelText);
|
||||
await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled());
|
||||
fireEvent.press(getByText('Registrer meg'));
|
||||
await fireEvent.press(getByText('Registrer meg'));
|
||||
|
||||
// the spinner shows while the request is in flight
|
||||
await waitFor(() => expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy());
|
||||
await waitFor(() => expect(getByRole('progressbar')).toBeTruthy());
|
||||
expect(registerUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
first_name: 'Grisen',
|
||||
@@ -92,22 +87,22 @@ it('shows server field errors on the matching form field', async () => {
|
||||
jest
|
||||
.spyOn(api, 'registerUser')
|
||||
.mockRejectedValueOnce(new ApiError({ email: ['Denne e-postadressen er allerede i bruk'] }, 400));
|
||||
const { getByText, getByRole, getByLabelText, findByText } = renderWithProviders(<UserRegister />);
|
||||
const { getByText, getByRole, getByLabelText, findByText } = await renderWithProviders(<UserRegister />);
|
||||
|
||||
fillForm(getByLabelText);
|
||||
await fillForm(getByLabelText);
|
||||
await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled());
|
||||
fireEvent.press(getByText('Registrer meg'));
|
||||
await fireEvent.press(getByText('Registrer meg'));
|
||||
|
||||
expect(await findByText('Denne e-postadressen er allerede i bruk')).toBeTruthy();
|
||||
expect(mockGoBack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves focus down the form when a field is submitted from the keyboard', async () => {
|
||||
const { getByLabelText } = renderWithProviders(<UserRegister />);
|
||||
const { getByLabelText } = await renderWithProviders(<UserRegister />);
|
||||
const labels = ['Fornavn', 'Etternavn', 'E-post', 'Mobilnummer', 'Passord'];
|
||||
|
||||
for (const [i, label] of labels.slice(0, -1).entries()) {
|
||||
fireEvent(getByLabelText(label), 'submitEditing');
|
||||
await fireEvent(getByLabelText(label), 'submitEditing');
|
||||
// react-hook-form's setFocus defers the focus call to the next tick
|
||||
await waitFor(() => expect(focusedFieldLabel()).toBe(labels[i + 1]));
|
||||
}
|
||||
@@ -115,41 +110,23 @@ it('moves focus down the form when a field is submitted from the keyboard', asyn
|
||||
|
||||
it('shows a generic error when registration fails on the network', async () => {
|
||||
jest.spyOn(api, 'registerUser').mockRejectedValueOnce(new Error('network down'));
|
||||
const { getByText, getByRole, getByLabelText, findByText } = renderWithProviders(<UserRegister />);
|
||||
const { getByText, getByRole, getByLabelText, findByText } = await renderWithProviders(<UserRegister />);
|
||||
|
||||
fillForm(getByLabelText);
|
||||
await fillForm(getByLabelText);
|
||||
await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled());
|
||||
fireEvent.press(getByText('Registrer meg'));
|
||||
await fireEvent.press(getByText('Registrer meg'));
|
||||
|
||||
expect(await findByText('Kunne ikke sende inn skjemaet! Prøv igjen senere...')).toBeTruthy();
|
||||
expect(mockGoBack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns instead of submitting when the keyboard submits before validation has caught up', async () => {
|
||||
jest.useFakeTimers();
|
||||
const registerUser = jest.spyOn(api, 'registerUser');
|
||||
const { getByLabelText, findByText, queryByText } = renderWithProviders(<UserRegister />);
|
||||
|
||||
// Filling in and submitting in the same tick reaches the submit handler while isValid still reflects the empty form
|
||||
fillForm(getByLabelText);
|
||||
fireEvent(getByLabelText('Passord'), 'submitEditing');
|
||||
|
||||
const message = 'Noen av feltene er ikke fylt ut riktig';
|
||||
expect(await findByText(message)).toBeTruthy();
|
||||
expect(registerUser).not.toHaveBeenCalled();
|
||||
|
||||
// The toast dismisses itself after five seconds
|
||||
act(() => jest.advanceTimersByTime(5000));
|
||||
expect(queryByText(message)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an invalid e-mail address without submitting', async () => {
|
||||
const registerUser = jest.spyOn(api, 'registerUser');
|
||||
const { getByLabelText, findByText } = renderWithProviders(<UserRegister />);
|
||||
const { getByLabelText, findByText } = await renderWithProviders(<UserRegister />);
|
||||
|
||||
fillForm(getByLabelText, { 'E-post': 'not-an-email' });
|
||||
await fillForm(getByLabelText, { 'E-post': 'not-an-email' });
|
||||
// Enter on the last field submits too, bypassing the disabled button
|
||||
fireEvent(getByLabelText('Passord'), 'submitEditing');
|
||||
await fireEvent(getByLabelText('Passord'), 'submitEditing');
|
||||
|
||||
expect(await findByText('er ikke en gyldig e-post')).toBeTruthy();
|
||||
expect(registerUser).not.toHaveBeenCalled();
|
||||
|
||||
@@ -6,24 +6,13 @@ import Button from '@/components/Button';
|
||||
import FormErrors from '@/components/FormErrors';
|
||||
import FormInput from '@/components/FormInput';
|
||||
import PrivacyButton from '@/components/PrivacyButton';
|
||||
import Toast from '@/components/Toast';
|
||||
import theme from '@/theme';
|
||||
|
||||
import useUserRegister from './useUserRegister';
|
||||
|
||||
const UserRegister = () => {
|
||||
const {
|
||||
isRegisteringUser,
|
||||
onSubmit,
|
||||
isValid,
|
||||
isSubmitted,
|
||||
control,
|
||||
errors,
|
||||
registerError,
|
||||
setFocus,
|
||||
toast,
|
||||
setToast,
|
||||
} = useUserRegister();
|
||||
const { isRegisteringUser, onSubmit, isValid, isSubmitted, control, errors, registerError, setFocus } =
|
||||
useUserRegister();
|
||||
const isDisabled = isRegisteringUser || !isValid;
|
||||
|
||||
return (
|
||||
@@ -78,21 +67,14 @@ const UserRegister = () => {
|
||||
onSubmitEditing={onSubmit}
|
||||
/>
|
||||
{registerError && isSubmitted && <FormErrors errors={registerError} />}
|
||||
{isRegisteringUser && <ActivityIndicator color={theme.colors.neufOrange} />}
|
||||
{isRegisteringUser && (
|
||||
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.neufOrange} />
|
||||
)}
|
||||
<Button fullWidth disabled={isDisabled} onPress={onSubmit} style={[theme.button, styles.registerButton]}>
|
||||
<Text style={theme.buttonText}>Registrer meg</Text>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
<PrivacyButton />
|
||||
<Toast
|
||||
message={toast?.message}
|
||||
visible={toast?.visible}
|
||||
position="bottom"
|
||||
autoDismiss={5 * 1000}
|
||||
onDismiss={() => {
|
||||
setToast(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { ApiError } from '@/errors';
|
||||
import { useRegisterUserMutation } from '@/queries/useRegisterUserMutation';
|
||||
import type { UserRegisterFormValues } from '@/types/forms';
|
||||
import { ToastProps } from '@/types/toast';
|
||||
|
||||
export type { UserRegisterFormValues };
|
||||
|
||||
const useUserRegister = () => {
|
||||
const navigation = useNavigation();
|
||||
const { mutate: register, isPending, error } = useRegisterUserMutation();
|
||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||
|
||||
const {
|
||||
control,
|
||||
@@ -25,16 +22,6 @@ const useUserRegister = () => {
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
clearErrors();
|
||||
if (!isValid) {
|
||||
setToast({
|
||||
message: 'Noen av feltene er ikke fylt ut riktig',
|
||||
visible: true,
|
||||
preset: 'failure',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setToast(null);
|
||||
|
||||
register(values, {
|
||||
onSuccess: () => navigation.goBack(),
|
||||
onError: (registerError) => {
|
||||
@@ -60,8 +47,6 @@ const useUserRegister = () => {
|
||||
control,
|
||||
errors,
|
||||
setFocus,
|
||||
toast,
|
||||
setToast,
|
||||
};
|
||||
};
|
||||
export default useUserRegister;
|
||||
+2
-2
@@ -48,7 +48,7 @@ interface RenderWithProvidersOptions {
|
||||
* Renders a screen inside the providers it expects in the real app: a fresh
|
||||
* query client (no persister), AuthProvider and a NavigationContainer.
|
||||
*/
|
||||
export function renderWithProviders(
|
||||
export async function renderWithProviders(
|
||||
ui: ReactElement,
|
||||
{
|
||||
initialToken = null,
|
||||
@@ -71,7 +71,7 @@ export function renderWithProviders(
|
||||
queryClient.setQueryData(queryKeys.membershipTypes, initialMembershipTypes);
|
||||
}
|
||||
|
||||
const result = render(
|
||||
const result = await render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider initialToken={initialToken}>
|
||||
<NavigationContainer>{ui}</NavigationContainer>
|
||||
|
||||
Reference in new issue
Block a user