Add more tests before revamping state management

This commit is contained in:
ponas committed 2026-07-31 01:18:44 +02:00
1 parent 4fe1a1cdc0
commit f18deffa7b
8 files changed
+228 -31

No files matched your search

+15
View File
@@ -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);
});
-1
View File
@@ -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[]>([]);
+78
View File
@@ -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();
});
+56 -10
View File
@@ -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' }),
}),
);
});
+2 -1
View File
@@ -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,
},
);
+44 -2
View File
@@ -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();
});
+22 -11
View File
@@ -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
View File
@@ -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 };
}