From 87b8d6c851bbe33126863cdbba1574ffa7c93dc9 Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Sat, 5 Sep 2026 18:33:23 +0200 Subject: [PATCH] Bump react-hook-form, add more covrage for login and register forms --- package-lock.json | 8 ++-- package.json | 2 +- src/modules/login/Login.test.tsx | 21 +++++++++- src/modules/register/UserRegister.test.tsx | 47 +++++++++++++++++++++- src/testUtils.tsx | 5 +++ 5 files changed, 76 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 823e413..6c54c93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "expo-splash-screen": "~57.0.8", "expo-status-bar": "~57.0.1", "react": "19.2.3", - "react-hook-form": "^7.52.1", + "react-hook-form": "^7.87.0", "react-native": "0.86.3", "react-native-fast-confetti": "^2.0.2", "react-native-gesture-handler": "~2.32.0", @@ -13663,9 +13663,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.83.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.83.0.tgz", - "integrity": "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A==", + "version": "7.87.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.87.0.tgz", + "integrity": "sha512-zhFzWvLxNHH+8839OnZcUxgMZw88ah2jZWDWvKWgF3Tpbnd0vKL+dlcuU3nZVWESZQjd81EW8K+wU+cYfYAc0w==", "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/package.json b/package.json index 0e2c571..cfb8a19 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "expo-splash-screen": "~57.0.8", "expo-status-bar": "~57.0.1", "react": "19.2.3", - "react-hook-form": "^7.52.1", + "react-hook-form": "^7.87.0", "react-native": "0.86.3", "react-native-fast-confetti": "^2.0.2", "react-native-gesture-handler": "~2.32.0", diff --git a/src/modules/login/Login.test.tsx b/src/modules/login/Login.test.tsx index 7a579e5..d308970 100644 --- a/src/modules/login/Login.test.tsx +++ b/src/modules/login/Login.test.tsx @@ -1,10 +1,11 @@ import { fireEvent, waitFor, type RenderResult } from '@testing-library/react-native'; import React from 'react'; +import { Linking } from 'react-native'; import * as api from '@/api'; import { ApiError } from '@/errors'; import Login from '@/modules/login/Login'; -import { renderWithProviders } from '@/testUtils'; +import { focusedFieldLabel, 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. @@ -35,6 +36,24 @@ it('renders the login form', () => { expect(getByText('Glemt passord?')).toBeTruthy(); }); +it('moves focus to the password field when the username is submitted from the keyboard', async () => { + const { getByLabelText } = renderWithProviders(); + + 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(); + + 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/'); +}); + 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(); diff --git a/src/modules/register/UserRegister.test.tsx b/src/modules/register/UserRegister.test.tsx index 05c71ea..b286ab7 100644 --- a/src/modules/register/UserRegister.test.tsx +++ b/src/modules/register/UserRegister.test.tsx @@ -5,7 +5,7 @@ import { ActivityIndicator } from 'react-native'; import * as api from '@/api'; import { ApiError } from '@/errors'; import UserRegister from '@/modules/register/UserRegister'; -import { renderWithProviders, testUser } from '@/testUtils'; +import { focusedFieldLabel, renderWithProviders, testUser } from '@/testUtils'; import type { UserType } from '@/types/data'; // The screen is pushed on top of Login and closes itself with goBack() once @@ -34,6 +34,10 @@ beforeEach(() => { jest.clearAllMocks(); }); +afterEach(() => { + jest.useRealTimers(); +}); + it('renders the registration form', () => { const { getByText } = renderWithProviders(); @@ -98,6 +102,47 @@ it('shows server field errors on the matching form field', async () => { expect(mockGoBack).not.toHaveBeenCalled(); }); +it('moves focus down the form when a field is submitted from the keyboard', async () => { + const { getByLabelText } = renderWithProviders(); + const labels = ['Fornavn', 'Etternavn', 'E-post', 'Mobilnummer', 'Passord']; + + for (const [i, label] of labels.slice(0, -1).entries()) { + 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])); + } +}); + +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(); + + fillForm(getByLabelText); + await waitFor(() => expect(getByRole('button', { name: 'Registrer meg' })).toBeEnabled()); + 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(); + + // 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(); diff --git a/src/testUtils.tsx b/src/testUtils.tsx index 74dcb89..21d3c3c 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render } from '@testing-library/react-native'; import React from 'react'; import type { ReactElement } from 'react'; +import { TextInput } from 'react-native'; import { AuthProvider } from '@/auth/AuthContext'; import { queryKeys } from '@/queries/queryKeys'; @@ -29,6 +30,10 @@ export const testMembershipTypes: MembershipTypeOption[] = [ { slug: 'support', price: 50000 }, ]; +/** Label of the field focused last; the mocked TextInput shares a single focus() mock across instances. */ +export const focusedFieldLabel = () => + (TextInput.prototype.focus as jest.Mock).mock.contexts.at(-1)?.props.accessibilityLabel; + interface RenderWithProvidersOptions { /** Auth token the app has stored; being set means the user is logged in. */ initialToken?: string | null;