Skip to content

Commit 90a277b

Browse files
Exo1iim-saif
andauthored
feat: Add OTP resend timer functionality to ConfirmCode - [CU-869bgc23c] (#242)
Co-authored-by: Saif <78622218+im-saif@users.noreply.github.com>
1 parent e0d5006 commit 90a277b

2 files changed

Lines changed: 184 additions & 17 deletions

File tree

src/__tests__/screens/auth/password/ConfirmCode.test.tsx

Lines changed: 146 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ReactNode } from 'react';
22

33
import { NavigationContainer } from '@react-navigation/native';
4-
import { fireEvent, render, waitFor } from '@testing-library/react-native';
4+
import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
55

66
import { ConfirmCode } from '@/screens/auth/password/ConfirmCode';
77
import { resendResetOtp, verifyResetCode } from '@/services/auth';
@@ -39,9 +39,14 @@ const mockResendResetOtp = resendResetOtp as jest.MockedFunction<typeof resendRe
3939
describe('ConfirmCode Component', () => {
4040
beforeEach(() => {
4141
jest.clearAllMocks();
42+
jest.useFakeTimers();
4243
});
4344

44-
it('renders correctly with initial state', () => {
45+
afterEach(() => {
46+
jest.useRealTimers();
47+
});
48+
49+
it('renders correctly with initial state and timer', () => {
4550
const { getByText, getByTestId } = render(
4651
<NavigationContainer>
4752
<ConfirmCode />
@@ -51,7 +56,8 @@ describe('ConfirmCode Component', () => {
5156
expect(getByText('We sent you a code')).toBeTruthy();
5257
expect(getByTestId('text-input')).toBeTruthy();
5358
expect(getByText('Confirm')).toBeTruthy();
54-
expect(getByText('Resend')).toBeTruthy();
59+
// Timer should be shown on initial load
60+
expect(getByText('Resend code in 60s')).toBeTruthy();
5561
});
5662

5763
it('validates code input', () => {
@@ -126,18 +132,27 @@ describe('ConfirmCode Component', () => {
126132
});
127133
});
128134

129-
it('handles resend OTP successfully', async () => {
135+
it('handles resend OTP successfully and resets timer', async () => {
130136
mockResendResetOtp.mockResolvedValue({
131137
success: true,
132138
message: 'A new code has been sent',
133139
});
134140

135-
const { getByText } = render(
141+
const { getByText, queryByText } = render(
136142
<NavigationContainer>
137143
<ConfirmCode />
138144
</NavigationContainer>
139145
);
140146

147+
// Wait for timer to expire so resend button appears
148+
act(() => {
149+
jest.advanceTimersByTime(60000);
150+
});
151+
152+
await waitFor(() => {
153+
expect(getByText('Resend')).toBeTruthy();
154+
});
155+
141156
const resendButton = getByText('Resend');
142157
fireEvent.press(resendButton);
143158

@@ -147,6 +162,10 @@ describe('ConfirmCode Component', () => {
147162
});
148163
expect(getByText('A new code has been sent to your email')).toBeTruthy();
149164
});
165+
166+
// Timer should be reset to 60s after successful resend
167+
expect(getByText('Resend code in 60s')).toBeTruthy();
168+
expect(queryByText(/^Resend$/)).toBeNull();
150169
});
151170

152171
it('handles resend OTP error', async () => {
@@ -161,6 +180,15 @@ describe('ConfirmCode Component', () => {
161180
</NavigationContainer>
162181
);
163182

183+
// Wait for timer to expire so resend button appears
184+
act(() => {
185+
jest.advanceTimersByTime(60000);
186+
});
187+
188+
await waitFor(() => {
189+
expect(getByText('Resend')).toBeTruthy();
190+
});
191+
164192
const resendButton = getByText('Resend');
165193
fireEvent.press(resendButton);
166194

@@ -205,13 +233,28 @@ describe('ConfirmCode Component', () => {
205233
</NavigationContainer>
206234
);
207235

236+
// Wait for timer to expire so resend button appears
237+
act(() => {
238+
jest.advanceTimersByTime(60000);
239+
});
240+
241+
await waitFor(() => {
242+
expect(getByText('Resend')).toBeTruthy();
243+
});
244+
208245
const resendButton = getByText('Resend');
209246
fireEvent.press(resendButton);
210247

211248
expect(getByText('Sending...')).toBeTruthy();
212249

250+
// Advance timers to resolve the promise
251+
act(() => {
252+
jest.advanceTimersByTime(100);
253+
});
254+
213255
await waitFor(() => {
214-
expect(getByText('Resend')).toBeTruthy(); // Button text should return to normal
256+
// After successful resend, timer should be shown instead of Resend button
257+
expect(getByText('Resend code in 60s')).toBeTruthy();
215258
});
216259
});
217260

@@ -259,4 +302,101 @@ describe('ConfirmCode Component', () => {
259302

260303
expect(mockGoBack).toHaveBeenCalled();
261304
});
305+
306+
it('timer starts on screen load and prevents immediate resend', async () => {
307+
const { getByText, queryByText } = render(
308+
<NavigationContainer>
309+
<ConfirmCode />
310+
</NavigationContainer>
311+
);
312+
313+
// Timer should be shown immediately on load
314+
expect(getByText('Resend code in 60s')).toBeTruthy();
315+
expect(queryByText(/^Resend$/)).toBeNull();
316+
317+
// resendResetOtp should not have been called
318+
expect(mockResendResetOtp).not.toHaveBeenCalled();
319+
});
320+
321+
it('counts down the timer correctly from initial load', async () => {
322+
const { getByText } = render(
323+
<NavigationContainer>
324+
<ConfirmCode />
325+
</NavigationContainer>
326+
);
327+
328+
// Timer should start at 60s on load
329+
expect(getByText('Resend code in 60s')).toBeTruthy();
330+
331+
// Advance timer by 1 second
332+
act(() => {
333+
jest.advanceTimersByTime(1000);
334+
});
335+
336+
await waitFor(() => {
337+
expect(getByText('Resend code in 59s')).toBeTruthy();
338+
});
339+
340+
// Advance timer by 10 more seconds
341+
act(() => {
342+
jest.advanceTimersByTime(10000);
343+
});
344+
345+
await waitFor(() => {
346+
expect(getByText('Resend code in 49s')).toBeTruthy();
347+
});
348+
});
349+
350+
it('shows resend button after timer expires on initial load', async () => {
351+
const { getByText } = render(
352+
<NavigationContainer>
353+
<ConfirmCode />
354+
</NavigationContainer>
355+
);
356+
357+
// Timer should start at 60s
358+
expect(getByText('Resend code in 60s')).toBeTruthy();
359+
360+
// Advance timer by 60 seconds to expire the timer
361+
act(() => {
362+
jest.advanceTimersByTime(60000);
363+
});
364+
365+
await waitFor(() => {
366+
expect(getByText('Resend')).toBeTruthy();
367+
});
368+
});
369+
370+
it('does not start timer on failed resend', async () => {
371+
mockResendResetOtp.mockResolvedValue({
372+
success: false,
373+
message: 'Failed to resend code',
374+
});
375+
376+
const { getByText, queryByText } = render(
377+
<NavigationContainer>
378+
<ConfirmCode />
379+
</NavigationContainer>
380+
);
381+
382+
// Wait for initial timer to expire
383+
act(() => {
384+
jest.advanceTimersByTime(60000);
385+
});
386+
387+
await waitFor(() => {
388+
expect(getByText('Resend')).toBeTruthy();
389+
});
390+
391+
const resendButton = getByText('Resend');
392+
fireEvent.press(resendButton);
393+
394+
await waitFor(() => {
395+
expect(getByText('Failed to resend code')).toBeTruthy();
396+
});
397+
398+
// Timer should not restart on failure - resend button should still be available
399+
expect(queryByText(/Resend code in/)).toBeNull();
400+
expect(getByText('Resend')).toBeTruthy();
401+
});
262402
});

src/screens/auth/password/ConfirmCode.tsx

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo, useState } from 'react';
1+
import { useEffect, useMemo, useState } from 'react';
22

33
import { KeyboardAvoidingView, Text, View } from 'react-native';
44

@@ -37,6 +37,25 @@ export function ConfirmCode() {
3737
const [hasChangedAfterError, setHasChangedAfterError] = useState(true);
3838
const [resendLoading, setResendLoading] = useState(false);
3939
const [resendMessage, setResendMessage] = useState<string | undefined>(undefined);
40+
const [resendTimer, setResendTimer] = useState(60);
41+
42+
useEffect(() => {
43+
let interval: ReturnType<typeof setInterval> | null = null;
44+
if (resendTimer > 0) {
45+
interval = setInterval(() => {
46+
setResendTimer((prev) => {
47+
if (prev <= 1) {
48+
if (interval) clearInterval(interval);
49+
return 0;
50+
}
51+
return prev - 1;
52+
});
53+
}, 1000);
54+
}
55+
return () => {
56+
if (interval) clearInterval(interval);
57+
};
58+
}, [resendTimer]);
4059

4160
const valid = useMemo(() => code.trim().length === 6, [code]);
4261

@@ -85,13 +104,15 @@ export function ConfirmCode() {
85104
};
86105

87106
const handleResend = async () => {
107+
if (resendLoading || resendTimer > 0) return;
88108
setResendLoading(true);
89109
setResendMessage(undefined);
90110

91111
try {
92112
const response = await resendResetOtp({ confirmationToken });
93113
if (response.success) {
94114
setResendMessage('A new code has been sent to your email');
115+
setResendTimer(60);
95116
} else {
96117
handleApiFormError(response, {
97118
setFieldError: (field: string, message?: string) => {
@@ -167,16 +188,22 @@ export function ConfirmCode() {
167188

168189
<View className="">
169190
<Text className="text-sm" style={{ color: mutedTextColor }}>
170-
Didn&apos;t receive the code?{' '}
171-
<Text
172-
className="font-semibold underline"
173-
style={{ color: colors[theme].primary }}
174-
onPress={handleResend}
175-
disabled={resendLoading}
176-
accessibilityLabel="confirm-code-resend-button"
177-
>
178-
{resendLoading ? 'Sending...' : 'Resend'}
179-
</Text>
191+
{resendTimer > 0 ? (
192+
`Resend code in ${resendTimer}s`
193+
) : (
194+
<>
195+
Didn&apos;t receive the code?{' '}
196+
<Text
197+
className="font-semibold underline"
198+
style={{ color: colors[theme].primary }}
199+
onPress={handleResend}
200+
disabled={resendLoading || resendTimer > 0}
201+
accessibilityLabel="confirm-code-resend-button"
202+
>
203+
{resendLoading ? 'Sending...' : 'Resend'}
204+
</Text>
205+
</>
206+
)}
180207
</Text>
181208
{resendMessage && (
182209
<Text

0 commit comments

Comments
 (0)