From f18deffa7bb327e4d15b7e6388c5e46c6a6cd8a7 Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Fri, 31 Jul 2026 01:18:44 +0200 Subject: [PATCH] Add more tests before revamping state management --- src/modules/events/EventList.test.tsx | 15 +++++ src/modules/events/useEventList.tsx | 1 - src/modules/login/Login.test.tsx | 78 ++++++++++++++++++++++ src/modules/membership/Membership.test.tsx | 66 +++++++++++++++--- src/modules/membership/Proof.test.tsx | 3 +- src/modules/membership/UserDelete.test.tsx | 46 ++++++++++++- src/modules/register/UserRegister.test.tsx | 33 ++++++--- src/testUtils.tsx | 17 +++-- 8 files changed, 228 insertions(+), 31 deletions(-) diff --git a/src/modules/events/EventList.test.tsx b/src/modules/events/EventList.test.tsx index 55b4cda..7a42811 100644 --- a/src/modules/events/EventList.test.tsx +++ b/src/modules/events/EventList.test.tsx @@ -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); +}); diff --git a/src/modules/events/useEventList.tsx b/src/modules/events/useEventList.tsx index 72f75d3..2d44d3b 100644 --- a/src/modules/events/useEventList.tsx +++ b/src/modules/events/useEventList.tsx @@ -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([]); diff --git a/src/modules/login/Login.test.tsx b/src/modules/login/Login.test.tsx index 5ffc968..5a00b17 100644 --- a/src/modules/login/Login.test.tsx +++ b/src/modules/login/Login.test.tsx @@ -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(); @@ -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(); + + 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(); + + 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(); + + 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(); + + fillForm(getByLabelText, { username: 'member', password: 'whatever' }); + fireEvent.press(getByText('Logg inn')); + + await waitFor(() => expect(mockGoBack).toHaveBeenCalled()); + expect(fetchMock).not.toHaveBeenCalled(); +}); diff --git a/src/modules/membership/Membership.test.tsx b/src/modules/membership/Membership.test.tsx index a03acf5..795c105 100644 --- a/src/modules/membership/Membership.test.tsx +++ b/src/modules/membership/Membership.test.tsx @@ -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(, { - 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(, { - 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(, { + 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(, { + 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' }), + }), + ); +}); diff --git a/src/modules/membership/Proof.test.tsx b/src/modules/membership/Proof.test.tsx index f6e57af..dd8412d 100644 --- a/src/modules/membership/Proof.test.tsx +++ b/src/modules/membership/Proof.test.tsx @@ -65,7 +65,8 @@ const renderProof = ({ renderWithProviders( , { - preloadedState: { isAuthenticated: true, userToken: 'test-token', user }, + initialToken: 'test-token', + initialUser: user, apolloMocks: mocks, }, ); diff --git a/src/modules/membership/UserDelete.test.tsx b/src/modules/membership/UserDelete.test.tsx index 2cfd69a..e33f4d3 100644 --- a/src/modules/membership/UserDelete.test.tsx +++ b/src/modules/membership/UserDelete.test.tsx @@ -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(, { - 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(, { + 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(, { + 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(); +}); diff --git a/src/modules/register/UserRegister.test.tsx b/src/modules/register/UserRegister.test.tsx index 714ec3f..4d2ec0b 100644 --- a/src/modules/register/UserRegister.test.tsx +++ b/src/modules/register/UserRegister.test.tsx @@ -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(); + const { getByText, getByRole, UNSAFE_queryByType } = renderWithProviders(); 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(); +it('submits the form, shows a spinner while pending and navigates back on success', async () => { + let resolveRegister!: (response: { ok: boolean; status: number; json: () => Promise }) => void; + fetchMock.mockImplementationOnce(() => new Promise((resolve) => (resolveRegister = resolve))); + const { getByText, getByRole, getByLabelText, UNSAFE_getByType } = renderWithProviders(); fillForm(getByLabelText); await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled()); fireEvent.press(getByText('Registrer meg')); - await waitFor(() => expect(store.getState().isAuthenticated).toBe(true)); + // 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(); + const { getByText, getByRole, getByLabelText, findByText } = renderWithProviders(); fillForm(getByLabelText); await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled()); fireEvent.press(getByText('Registrer meg')); expect(await findByText('Denne e-postadressen er allerede i bruk')).toBeTruthy(); - expect(store.getState().isAuthenticated).toBe(false); expect(mockGoBack).not.toHaveBeenCalled(); }); diff --git a/src/testUtils.tsx b/src/testUtils.tsx index e9807ec..cf8b0f4 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -31,7 +31,8 @@ export const testUser: UserType = { }; interface RenderWithProvidersOptions { - preloadedState?: Partial; + 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( {ui} , ); - - return { store, ...result }; }