Skip to content

Commit 2bf4ec1

Browse files
authored
feat: implement change password functionality - [CU-869arhppx] (#70)
1 parent bcbe0d6 commit 2bf4ec1

10 files changed

Lines changed: 858 additions & 21 deletions

File tree

src/__tests__/accountInformation/AccountInformationScreen.test.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,15 +81,20 @@ describe('AccountInformationScreen', () => {
8181
expect(mockNavigation.goBack).toHaveBeenCalledTimes(1);
8282

8383
fireEvent.press(getByTestId('username-row'));
84-
expect(mockNavigation.navigate).toHaveBeenCalledWith('ChangeUsername', {
85-
currentUsername: 'testuser',
84+
expect(mockNavigation.navigate).toHaveBeenCalledWith('AccountInformation', {
85+
screen: 'ChangeUsername',
86+
params: { currentUsername: 'testuser' },
8687
});
8788

8889
fireEvent.press(getByTestId('email-row'));
89-
expect(mockNavigation.navigate).toHaveBeenCalledWith('ChangeEmail', {
90-
currentEmail: 'test@example.com',
90+
expect(mockNavigation.navigate).toHaveBeenCalledWith('AccountInformation', {
91+
screen: 'ChangeEmail',
92+
params: { currentEmail: 'test@example.com' },
9193
});
9294

95+
fireEvent.press(getByTestId('password-row'));
96+
expect(mockNavigation.navigate).toHaveBeenCalledWith('ChangePassword');
97+
9398
fireEvent.press(getByTestId('logout-button'));
9499

95100
await waitFor(() => expect(mockClearActiveSession).toHaveBeenCalledTimes(1));
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
import { ReactNode } from 'react';
2+
3+
import { NavigationContainer } from '@react-navigation/native';
4+
import { fireEvent, render, waitFor, within } from '@testing-library/react-native';
5+
6+
import { ApiException } from '@/libs/api';
7+
import ChangePasswordScreen from '@/screens/accountInformation/ChangePasswordScreen';
8+
import { changePassword } from '@/services/settings';
9+
10+
jest.mock('@/services/settings', () => ({ changePassword: jest.fn() }));
11+
12+
const mockNavigation = { navigate: jest.fn(), goBack: jest.fn() };
13+
jest.mock('@react-navigation/native', () => ({
14+
NavigationContainer: ({ children }: { children?: ReactNode }) => <>{children}</>,
15+
useNavigation: () => mockNavigation,
16+
}));
17+
18+
jest.mock('@/hooks/useTheme', () => ({ useTheme: () => ({ theme: 'light' as const }) }));
19+
jest.mock('@expo/vector-icons', () => ({ Ionicons: () => null }));
20+
jest.mock('@react-navigation/elements', () => ({
21+
useHeaderHeight: () => 50,
22+
}));
23+
24+
const originalConsoleError = console.error;
25+
beforeAll(() => {
26+
console.error = jest.fn();
27+
});
28+
afterAll(() => {
29+
console.error = originalConsoleError;
30+
});
31+
32+
describe('ChangePasswordScreen', () => {
33+
beforeEach(() => {
34+
jest.clearAllMocks();
35+
});
36+
37+
const renderScreen = () =>
38+
render(
39+
<NavigationContainer>
40+
<ChangePasswordScreen />
41+
</NavigationContainer>
42+
);
43+
44+
it('renders with correct initial state and navigation', () => {
45+
const { getByText, getByTestId } = renderScreen();
46+
47+
// Check that the description is rendered
48+
expect(getByText(/Your password must be at least 10 characters long/)).toBeTruthy();
49+
// Check that the change password button is disabled initially
50+
expect(getByTestId('change-password-button').props.accessibilityState.disabled).toBe(true);
51+
52+
// Test navigation buttons
53+
fireEvent.press(getByTestId('cancel-button'));
54+
expect(mockNavigation.goBack).toHaveBeenCalledTimes(1);
55+
});
56+
57+
it('validates password requirements and shows appropriate errors', async () => {
58+
const { getByTestId } = renderScreen();
59+
60+
const currentPasswordInput = getByTestId('current-password-input');
61+
const newPasswordInput = getByTestId('new-password-input');
62+
const confirmPasswordInput = getByTestId('confirm-password-input');
63+
64+
// Initially button should be disabled
65+
expect(getByTestId('change-password-button').props.accessibilityState.disabled).toBe(true);
66+
67+
// Fill current password
68+
fireEvent.changeText(currentPasswordInput, 'currentpass');
69+
expect(getByTestId('change-password-button').props.accessibilityState.disabled).toBe(true);
70+
71+
// Fill new password (too short)
72+
fireEvent.changeText(newPasswordInput, 'short');
73+
expect(getByTestId('change-password-button').props.accessibilityState.disabled).toBe(true);
74+
75+
// Fill valid new password
76+
fireEvent.changeText(newPasswordInput, 'validpassword123');
77+
expect(getByTestId('change-password-button').props.accessibilityState.disabled).toBe(true);
78+
79+
// Fill confirm password (doesn't match)
80+
fireEvent.changeText(confirmPasswordInput, 'differentpassword');
81+
expect(getByTestId('change-password-button').props.accessibilityState.disabled).toBe(true);
82+
83+
// Fill matching confirm password
84+
fireEvent.changeText(confirmPasswordInput, 'validpassword123');
85+
await waitFor(() => {
86+
expect(getByTestId('change-password-button').props.accessibilityState.disabled).toBe(false);
87+
});
88+
});
89+
90+
it('handles successful password change', async () => {
91+
const { getByTestId, queryByText } = renderScreen();
92+
93+
const currentPasswordInput = getByTestId('current-password-input');
94+
const newPasswordInput = getByTestId('new-password-input');
95+
const confirmPasswordInput = getByTestId('confirm-password-input');
96+
97+
fireEvent.changeText(currentPasswordInput, 'currentpass');
98+
fireEvent.changeText(newPasswordInput, 'newpassword123');
99+
fireEvent.changeText(confirmPasswordInput, 'newpassword123');
100+
101+
(changePassword as jest.Mock).mockResolvedValueOnce({
102+
success: true,
103+
message: 'Password changed successfully.',
104+
});
105+
106+
fireEvent.press(getByTestId('change-password-button'));
107+
108+
await waitFor(() => {
109+
expect(changePassword).toHaveBeenCalledWith({
110+
currentPassword: 'currentpass',
111+
newPassword: 'newpassword123',
112+
});
113+
});
114+
115+
// Check success message appears
116+
expect(queryByText('Password changed successfully.')).toBeTruthy();
117+
118+
// Check form is cleared
119+
expect(currentPasswordInput.props.value).toBe('');
120+
expect(newPasswordInput.props.value).toBe('');
121+
expect(confirmPasswordInput.props.value).toBe('');
122+
});
123+
124+
it('handles successful password change with default message', async () => {
125+
const { getByTestId, queryByText } = renderScreen();
126+
127+
const currentPasswordInput = getByTestId('current-password-input');
128+
const newPasswordInput = getByTestId('new-password-input');
129+
const confirmPasswordInput = getByTestId('confirm-password-input');
130+
131+
fireEvent.changeText(currentPasswordInput, 'currentpass');
132+
fireEvent.changeText(newPasswordInput, 'newpassword123');
133+
fireEvent.changeText(confirmPasswordInput, 'newpassword123');
134+
135+
(changePassword as jest.Mock).mockResolvedValueOnce({
136+
success: true,
137+
});
138+
139+
fireEvent.press(getByTestId('change-password-button'));
140+
141+
await waitFor(() => {
142+
expect(queryByText('Password changed successfully.')).toBeTruthy();
143+
});
144+
});
145+
146+
it('handles failure with custom message', async () => {
147+
const { getByTestId, queryByText } = renderScreen();
148+
149+
const currentPasswordInput = getByTestId('current-password-input');
150+
const newPasswordInput = getByTestId('new-password-input');
151+
const confirmPasswordInput = getByTestId('confirm-password-input');
152+
153+
fireEvent.changeText(currentPasswordInput, 'currentpass');
154+
fireEvent.changeText(newPasswordInput, 'newpassword123');
155+
fireEvent.changeText(confirmPasswordInput, 'newpassword123');
156+
157+
const apiError = new ApiException(
158+
422,
159+
{
160+
success: false,
161+
error: {
162+
code: 'VALIDATION_ERROR',
163+
message: 'Current password is incorrect',
164+
errors: [
165+
{
166+
field: 'currentPassword',
167+
code: 'INVALID_PASSWORD',
168+
message: 'Current password is incorrect',
169+
},
170+
],
171+
},
172+
},
173+
'Current password is incorrect'
174+
);
175+
176+
(changePassword as jest.Mock).mockRejectedValueOnce(apiError);
177+
178+
fireEvent.press(getByTestId('change-password-button'));
179+
180+
await waitFor(() => {
181+
// Should show field error on current password input through helper text
182+
const helperText = queryByText('Current password is incorrect');
183+
expect(helperText).toBeTruthy();
184+
});
185+
});
186+
187+
it('handles failure without message', async () => {
188+
const { getByTestId } = renderScreen();
189+
190+
const currentPasswordInput = getByTestId('current-password-input');
191+
const newPasswordInput = getByTestId('new-password-input');
192+
const confirmPasswordInput = getByTestId('confirm-password-input');
193+
194+
fireEvent.changeText(currentPasswordInput, 'currentpass');
195+
fireEvent.changeText(newPasswordInput, 'newpassword123');
196+
fireEvent.changeText(confirmPasswordInput, 'newpassword123');
197+
198+
const apiError = {
199+
status: 500,
200+
body: {
201+
success: false,
202+
error: {
203+
code: 'INTERNAL_ERROR',
204+
message: 'Failed to change password',
205+
},
206+
},
207+
};
208+
209+
(changePassword as jest.Mock).mockRejectedValueOnce(apiError);
210+
211+
fireEvent.press(getByTestId('change-password-button'));
212+
213+
await waitFor(() => {
214+
// Button should be enabled again after error
215+
expect(getByTestId('change-password-button').props.accessibilityState.disabled).toBe(false);
216+
});
217+
});
218+
219+
it('handles exception during API call', async () => {
220+
const { getByTestId, queryByText } = renderScreen();
221+
222+
const currentPasswordInput = getByTestId('current-password-input');
223+
const newPasswordInput = getByTestId('new-password-input');
224+
const confirmPasswordInput = getByTestId('confirm-password-input');
225+
226+
fireEvent.changeText(currentPasswordInput, 'currentpass');
227+
fireEvent.changeText(newPasswordInput, 'newpassword123');
228+
fireEvent.changeText(confirmPasswordInput, 'newpassword123');
229+
230+
(changePassword as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
231+
232+
fireEvent.press(getByTestId('change-password-button'));
233+
234+
await waitFor(() => {
235+
expect(queryByText('Network error')).toBeTruthy();
236+
});
237+
});
238+
239+
it('handles generic exception', async () => {
240+
const { getByTestId } = renderScreen();
241+
242+
const currentPasswordInput = getByTestId('current-password-input');
243+
const newPasswordInput = getByTestId('new-password-input');
244+
const confirmPasswordInput = getByTestId('confirm-password-input');
245+
246+
fireEvent.changeText(currentPasswordInput, 'currentpass');
247+
fireEvent.changeText(newPasswordInput, 'newpassword123');
248+
fireEvent.changeText(confirmPasswordInput, 'newpassword123');
249+
250+
(changePassword as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
251+
252+
fireEvent.press(getByTestId('change-password-button'));
253+
254+
await waitFor(() => {
255+
// Button should be enabled again after error (toast will show the error)
256+
expect(getByTestId('change-password-button').props.accessibilityState.disabled).toBe(false);
257+
});
258+
});
259+
260+
it('shows loading state during API call', async () => {
261+
const { getByTestId } = renderScreen();
262+
263+
const currentPasswordInput = getByTestId('current-password-input');
264+
const newPasswordInput = getByTestId('new-password-input');
265+
const confirmPasswordInput = getByTestId('confirm-password-input');
266+
267+
fireEvent.changeText(currentPasswordInput, 'currentpass');
268+
fireEvent.changeText(newPasswordInput, 'newpassword123');
269+
fireEvent.changeText(confirmPasswordInput, 'newpassword123');
270+
271+
(changePassword as jest.Mock).mockImplementation(
272+
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
273+
);
274+
275+
fireEvent.press(getByTestId('change-password-button'));
276+
277+
// Check loading state
278+
expect(within(getByTestId('change-password-button')).getByText('Changing...')).toBeTruthy();
279+
expect(getByTestId('change-password-button').props.accessibilityState.disabled).toBe(true);
280+
281+
await waitFor(() => {
282+
expect(changePassword).toHaveBeenCalledTimes(1);
283+
});
284+
});
285+
286+
it('clears previous messages when starting new request', async () => {
287+
const { getByTestId, queryByText } = renderScreen();
288+
289+
const currentPasswordInput = getByTestId('current-password-input');
290+
const newPasswordInput = getByTestId('new-password-input');
291+
const confirmPasswordInput = getByTestId('confirm-password-input');
292+
293+
// First, set up a validation error state that will show on the UI
294+
fireEvent.changeText(currentPasswordInput, 'currentpass');
295+
fireEvent.changeText(newPasswordInput, 'newpassword123');
296+
fireEvent.changeText(confirmPasswordInput, 'newpassword123');
297+
298+
const apiError = new ApiException(
299+
422,
300+
{
301+
success: false,
302+
error: {
303+
code: 'VALIDATION_ERROR',
304+
message: 'Current password is incorrect',
305+
errors: [
306+
{
307+
field: 'currentPassword',
308+
code: 'INVALID_PASSWORD',
309+
message: 'Current password is incorrect',
310+
},
311+
],
312+
},
313+
},
314+
'Current password is incorrect'
315+
);
316+
317+
(changePassword as jest.Mock).mockRejectedValueOnce(apiError);
318+
319+
fireEvent.press(getByTestId('change-password-button'));
320+
321+
await waitFor(() => {
322+
expect(queryByText('Current password is incorrect')).toBeTruthy();
323+
});
324+
325+
// Now try again - error should be cleared
326+
(changePassword as jest.Mock).mockResolvedValueOnce({
327+
success: true,
328+
message: 'Success!',
329+
});
330+
331+
fireEvent.press(getByTestId('change-password-button'));
332+
333+
await waitFor(() => {
334+
expect(queryByText('Success!')).toBeTruthy();
335+
expect(queryByText('Current password is incorrect')).toBeFalsy();
336+
});
337+
});
338+
339+
it('does not submit when form is invalid', () => {
340+
const { getByTestId } = renderScreen();
341+
342+
const newPasswordInput = getByTestId('new-password-input');
343+
344+
// Only fill new password, leave others empty
345+
fireEvent.changeText(newPasswordInput, 'newpassword123');
346+
347+
fireEvent.press(getByTestId('change-password-button'));
348+
349+
expect(changePassword).not.toHaveBeenCalled();
350+
});
351+
});

0 commit comments

Comments
 (0)