Add more tests before revamping state management
This commit is contained in:
8 files changed
+228
-31
No files matched your search
@@ -1,6 +1,8 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
|
||||
import EventList from '@/modules/events/EventList';
|
||||
import useEventList from '@/modules/events/useEventList';
|
||||
import { renderWithProviders } from '@/testUtils';
|
||||
import { fetchWithTimeout } from '@/utils';
|
||||
|
||||
@@ -85,3 +87,16 @@ it('renders an error state when the API is unreachable', async () => {
|
||||
|
||||
expect(await findByText('Kunne ikke hente programmet...')).toBeTruthy();
|
||||
});
|
||||
|
||||
// stuck spinner bug, fix later
|
||||
it.failing('stops the refresh spinner and shows the error when refresh fails', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ json: async () => ({ events: [event] }) });
|
||||
const { result } = renderHook(() => useEventList());
|
||||
await waitFor(() => expect(result.current.events).toHaveLength(1));
|
||||
|
||||
mockFetch.mockRejectedValue(new Error('network down'));
|
||||
act(() => result.current.handleRefresh());
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBeTruthy());
|
||||
expect(result.current.refreshing).toBe(false);
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
|
||||
import type { EventType } from '@/types/data';
|
||||
import { fetchWithTimeout } from '@/utils';
|
||||
|
||||
/* Ref: https://medium.com/react-native-development/how-to-use-the-flatlist-component-react-native-basics-92c482816fe6 */
|
||||
const useEventList = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [events, setEvents] = useState<EventType[]>([]);
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
import { fireEvent, waitFor, type RenderResult } from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
|
||||
import Login from '@/modules/login/Login';
|
||||
import { renderWithProviders } from '@/testUtils';
|
||||
|
||||
// Login is pushed on top of Membership and closes itself with goBack() once
|
||||
// authentication 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 fillForm = (
|
||||
getByLabelText: RenderResult['getByLabelText'],
|
||||
{ username = 'grisen@example.test', password = 'hunter2' } = {},
|
||||
) => {
|
||||
fireEvent.changeText(getByLabelText('E-post / Brukernavn'), username);
|
||||
fireEvent.changeText(getByLabelText('Passord'), password);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the login form', () => {
|
||||
const { getByText } = renderWithProviders(<Login />);
|
||||
|
||||
@@ -11,3 +34,58 @@ it('renders the login form', () => {
|
||||
expect(getByText('Logg inn')).toBeTruthy();
|
||||
expect(getByText('Glemt passord?')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('logs in against the API and returns to the previous screen', async () => {
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ token: 'token-123' }) });
|
||||
const { getByText, getByLabelText } = renderWithProviders(<Login />);
|
||||
|
||||
fillForm(getByLabelText);
|
||||
fireEvent.press(getByText('Logg inn'));
|
||||
|
||||
await waitFor(() => expect(mockGoBack).toHaveBeenCalled());
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://dusken.example.test/auth/obtain-token/',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username: 'grisen@example.test', password: 'hunter2' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the server error when the credentials are rejected', async () => {
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ non_field_errors: ['Kan ikke logge inn med angitt brukernavn og passord'] }),
|
||||
});
|
||||
const { getByText, getByLabelText, findByText } = renderWithProviders(<Login />);
|
||||
|
||||
fillForm(getByLabelText);
|
||||
fireEvent.press(getByText('Logg inn'));
|
||||
|
||||
expect(await findByText('Kan ikke logge inn med angitt brukernavn og passord')).toBeTruthy();
|
||||
expect(mockGoBack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a generic message when the request fails on the network', async () => {
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
fetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
const { getByText, getByLabelText, findByText } = renderWithProviders(<Login />);
|
||||
|
||||
fillForm(getByLabelText);
|
||||
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 />);
|
||||
|
||||
fillForm(getByLabelText, { username: 'member', password: 'whatever' });
|
||||
fireEvent.press(getByText('Logg inn'));
|
||||
|
||||
await waitFor(() => expect(mockGoBack).toHaveBeenCalled());
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -44,11 +45,8 @@ it('lets a logged in non-member pick a membership type and shows its price', asy
|
||||
});
|
||||
|
||||
const { findByText, getByText } = renderWithProviders(<Membership />, {
|
||||
preloadedState: {
|
||||
isAuthenticated: true,
|
||||
userToken: 'test-token',
|
||||
user: nonMember,
|
||||
},
|
||||
initialToken: 'test-token',
|
||||
initialUser: nonMember,
|
||||
apolloMocks: [
|
||||
{
|
||||
request: { query: PROOF_QUERY },
|
||||
@@ -92,14 +90,62 @@ it('shows the membership proof when logged in', async () => {
|
||||
});
|
||||
|
||||
const { findByText } = renderWithProviders(<Membership />, {
|
||||
preloadedState: {
|
||||
isAuthenticated: true,
|
||||
userToken: 'test-token',
|
||||
user: testUser,
|
||||
},
|
||||
initialToken: 'test-token',
|
||||
initialUser: testUser,
|
||||
apolloMocks,
|
||||
});
|
||||
|
||||
expect(await findByText('MEDLEM')).toBeTruthy();
|
||||
expect(await findByText('Test Testesen')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fetches the user with the stored token when only the token survives a cold start', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => testUser,
|
||||
});
|
||||
|
||||
const { findByText } = renderWithProviders(<Membership />, {
|
||||
initialToken: 'test-token',
|
||||
apolloMocks,
|
||||
});
|
||||
|
||||
expect(await findByText('MEDLEM')).toBeTruthy();
|
||||
expect(await findByText('Test Testesen')).toBeTruthy();
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://dusken.example.test/api/me/',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({ Authorization: 'Token test-token' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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 };
|
||||
|
||||
// the refetch on mount still sees the non-member
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => nonMember });
|
||||
|
||||
const { findByText, UNSAFE_getByType } = renderWithProviders(<Membership />, {
|
||||
initialToken: 'test-token',
|
||||
initialUser: nonMember,
|
||||
apolloMocks,
|
||||
});
|
||||
expect(await findByText('Velg medlemskapstype')).toBeTruthy();
|
||||
|
||||
// but by the next pull-to-refresh the membership has been bought elsewhere
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, status: 200, json: async () => testUser });
|
||||
fireEvent(UNSAFE_getByType(RefreshControl), 'refresh');
|
||||
|
||||
expect(await findByText('MEDLEM')).toBeTruthy();
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
'https://dusken.example.test/api/me/',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({ Authorization: 'Token test-token' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -65,7 +65,8 @@ const renderProof = ({
|
||||
renderWithProviders(
|
||||
<Proof onLogoutPress={jest.fn()} fetchUser={fetchUser} isFetchingUserData={false} onDeletePress={jest.fn()} />,
|
||||
{
|
||||
preloadedState: { isAuthenticated: true, userToken: 'test-token', user },
|
||||
initialToken: 'test-token',
|
||||
initialUser: user,
|
||||
apolloMocks: mocks,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fireEvent, waitFor } from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
|
||||
import UserDelete from '@/modules/membership/UserDelete';
|
||||
@@ -9,13 +10,16 @@ jest.mock('@react-navigation/native', () => ({
|
||||
useNavigation: () => ({ navigate: mockNavigate, goBack: jest.fn() }),
|
||||
}));
|
||||
|
||||
const fetchMock = global.fetch as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockNavigate.mockClear();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the delete confirmation for a logged in user', () => {
|
||||
const { getByText } = renderWithProviders(<UserDelete />, {
|
||||
preloadedState: { isAuthenticated: true, userToken: 'test-token', user: testUser },
|
||||
initialToken: 'test-token',
|
||||
initialUser: testUser,
|
||||
});
|
||||
|
||||
expect(getByText('Slett meg')).toBeTruthy();
|
||||
@@ -28,3 +32,41 @@ it('renders nothing and navigates back to Membership when not logged in', () =>
|
||||
expect(queryByText('Slett meg')).toBeNull();
|
||||
expect(mockNavigate).toHaveBeenCalledWith('UserStack', { screen: 'Membership' });
|
||||
});
|
||||
|
||||
it('deletes the account and returns to Membership once the username is typed to confirm', async () => {
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) });
|
||||
const { getByText, getByDisplayValue } = renderWithProviders(<UserDelete />, {
|
||||
initialToken: 'test-token',
|
||||
initialUser: testUser,
|
||||
});
|
||||
|
||||
fireEvent.changeText(getByDisplayValue(''), 'testbruker');
|
||||
fireEvent.press(getByText('Jeg forstår, slett kontoen min'));
|
||||
|
||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('UserStack', { screen: 'Membership' }));
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://dusken.example.test/api/me/delete/',
|
||||
expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
headers: expect.objectContaining({ Authorization: 'Token test-token' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the server error when deletion fails', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ detail: 'Kunne ikke slette kontoen akkurat nå' }),
|
||||
});
|
||||
const { getByText, getByDisplayValue, findByText } = renderWithProviders(<UserDelete />, {
|
||||
initialToken: 'test-token',
|
||||
initialUser: testUser,
|
||||
});
|
||||
|
||||
fireEvent.changeText(getByDisplayValue(''), 'testbruker');
|
||||
fireEvent.press(getByText('Jeg forstår, slett kontoen min'));
|
||||
|
||||
expect(await findByText('Kunne ikke slette kontoen akkurat nå')).toBeTruthy();
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fireEvent, waitFor, type RenderResult } from '@testing-library/react-native';
|
||||
import { act, fireEvent, waitFor, type RenderResult } from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
import { ActivityIndicator } from 'react-native';
|
||||
|
||||
import UserRegister from '@/modules/register/UserRegister';
|
||||
import { renderWithProviders } from '@/testUtils';
|
||||
@@ -44,24 +45,26 @@ it('renders the registration form', () => {
|
||||
});
|
||||
|
||||
it('disables the submit button while the form is incomplete', () => {
|
||||
const { getByText, getByRole, store } = renderWithProviders(<UserRegister />);
|
||||
const { getByText, getByRole, UNSAFE_queryByType } = renderWithProviders(<UserRegister />);
|
||||
|
||||
expect(getByRole('button', { name: 'Registrer meg' })).toBeDisabled();
|
||||
fireEvent.press(getByText('Registrer meg'));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(store.getState().isRegisteringUser).toBe(false);
|
||||
expect(UNSAFE_queryByType(ActivityIndicator)).toBeNull();
|
||||
});
|
||||
|
||||
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 />);
|
||||
it('submits the form, shows a spinner while pending and navigates back on success', async () => {
|
||||
let resolveRegister!: (response: { ok: boolean; status: number; json: () => Promise<unknown> }) => void;
|
||||
fetchMock.mockImplementationOnce(() => new Promise((resolve) => (resolveRegister = resolve)));
|
||||
const { getByText, getByRole, getByLabelText, UNSAFE_getByType } = 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));
|
||||
// the spinner shows while the request is in flight
|
||||
await waitFor(() => expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy());
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://dusken.example.test/api/user/register/',
|
||||
expect.objectContaining({
|
||||
@@ -75,20 +78,28 @@ it('submits the form, authenticates and navigates back on success', async () =>
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(store.getState().userToken).toBe('token-456');
|
||||
expect(mockGoBack).toHaveBeenCalled();
|
||||
expect(mockGoBack).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveRegister({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ username: 'grisen', email: 'grisen@example.test', auth_token: 'token-456' }),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => 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 />);
|
||||
const { getByText, getByRole, getByLabelText, findByText } = 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();
|
||||
});
|
||||
|
||||
|
||||
+11
-6
@@ -31,7 +31,8 @@ export const testUser: UserType = {
|
||||
};
|
||||
|
||||
interface RenderWithProvidersOptions {
|
||||
preloadedState?: Partial<AppState>;
|
||||
initialToken?: string | null;
|
||||
initialUser?: UserType | null;
|
||||
apolloMocks?: readonly MockedResponse[];
|
||||
}
|
||||
|
||||
@@ -42,17 +43,21 @@ interface RenderWithProvidersOptions {
|
||||
*/
|
||||
export function renderWithProviders(
|
||||
ui: ReactElement,
|
||||
{ preloadedState, apolloMocks = [] }: RenderWithProvidersOptions = {},
|
||||
{ initialToken = null, initialUser = null, apolloMocks = [] }: RenderWithProvidersOptions = {},
|
||||
) {
|
||||
const store = createStore(rootReducer, { ...initialState, ...preloadedState }, applyMiddleware(thunk));
|
||||
const preloadedState: AppState = {
|
||||
...initialState,
|
||||
userToken: initialToken,
|
||||
isAuthenticated: !!initialToken,
|
||||
user: initialUser,
|
||||
};
|
||||
const store = createStore(rootReducer, preloadedState, applyMiddleware(thunk));
|
||||
|
||||
const result = render(
|
||||
return render(
|
||||
<MockedProvider mocks={apolloMocks} cache={new InMemoryCache()}>
|
||||
<Provider store={store}>
|
||||
<NavigationContainer>{ui}</NavigationContainer>
|
||||
</Provider>
|
||||
</MockedProvider>,
|
||||
);
|
||||
|
||||
return { store, ...result };
|
||||
}
|
||||
Reference in new issue
Block a user