diff --git a/src/api/client.test.ts b/src/api/client.test.ts index ce3dd30..9b7d142 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -60,6 +60,16 @@ describe('apiFetch', () => { expect(result).toBeUndefined() }) + it('returns undefined for a success response with an empty body regardless of status code', async () => { + // password-reset-requests처럼 202를 body 없이 반환하는 엔드포인트도 있다 — 204만 특별 취급하면 + // response.json()이 빈 문자열을 파싱하려다 실패한다. + vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 202 })) + + const result = await apiFetch('/auth/password-reset-requests', { method: 'POST' }) + + expect(result).toBeUndefined() + }) + it('throws an ApiError parsed from the response body on failure', async () => { vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(errorBody(), { status: 404 })) diff --git a/src/api/client.ts b/src/api/client.ts index eab4934..6f8ab1e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -119,9 +119,12 @@ export async function apiFetch(path: string, options: ApiFetchOptions = {}): throw await parseErrorBody(response, path) } - if (response.status === 204) { + // 204는 물론이고, password-reset-requests처럼 body 없이 202만 내려주는 응답도 있어 + // status 코드로만 분기하지 않고 실제 body가 비어있는지로 판단한다. + const text = await response.text() + if (!text) { return undefined as T } - return (await response.json()) as T + return JSON.parse(text) as T } diff --git a/src/pages/ForgotPasswordPage/ForgotPasswordPage.test.tsx b/src/pages/ForgotPasswordPage/ForgotPasswordPage.test.tsx index 89e2e4a..5fb9b74 100644 --- a/src/pages/ForgotPasswordPage/ForgotPasswordPage.test.tsx +++ b/src/pages/ForgotPasswordPage/ForgotPasswordPage.test.tsx @@ -1,9 +1,24 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router-dom' -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ForgotPasswordPage } from './ForgotPasswordPage' +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return new Response(JSON.stringify(body), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + ...init, + }) +} + +function errorResponse(status: number, code: string, message: string) { + return jsonResponse( + { timestamp: '2026-08-07T00:00:00Z', status, code, message, path: '/api/v1/auth/password-reset-requests', request_id: 'req-1', field_errors: [] }, + { status }, + ) +} + function renderPage() { render( @@ -15,6 +30,14 @@ function renderPage() { ) } +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + describe('ForgotPasswordPage', () => { it('renders the recovery form', () => { renderPage() @@ -32,13 +55,31 @@ describe('ForgotPasswordPage', () => { expect(screen.queryByText('email sent screen')).not.toBeInTheDocument() }) - it('navigates to /email-sent with the entered email on valid submit', async () => { + it('requests a reset link and navigates to /email-sent on valid submit', async () => { const user = userEvent.setup() + let requestBody: string | undefined + vi.mocked(fetch).mockImplementation((_input, init) => { + requestBody = init?.body as string | undefined + return Promise.resolve(new Response(null, { status: 202 })) + }) renderPage() await user.type(screen.getByLabelText('이메일'), 'mini@naver.com') await user.click(screen.getByRole('button', { name: '재설정 메일 보내기' })) expect(await screen.findByText('email sent screen')).toBeInTheDocument() + expect(JSON.parse(requestBody!)).toEqual({ email: 'mini@naver.com' }) + }) + + it('shows an error message when the request fails', async () => { + const user = userEvent.setup() + vi.mocked(fetch).mockResolvedValue(errorResponse(500, 'INTERNAL_SERVER_ERROR', 'raw')) + renderPage() + + await user.type(screen.getByLabelText('이메일'), 'mini@naver.com') + await user.click(screen.getByRole('button', { name: '재설정 메일 보내기' })) + + expect(await screen.findByText('일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.')).toBeInTheDocument() + expect(screen.queryByText('email sent screen')).not.toBeInTheDocument() }) }) diff --git a/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx b/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx index 8901958..e044ca2 100644 --- a/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx +++ b/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx @@ -1,5 +1,7 @@ import { useState, type FormEvent } from 'react' import { Link, useNavigate } from 'react-router-dom' +import { apiFetch } from '../../api/client' +import { ApiError, getErrorMessage } from '../../api/errors' import { Button } from '../../components/ui/Button/Button' import { MailIcon } from '../../components/ui/icons/FieldIcons' import styles from './ForgotPasswordPage.module.css' @@ -10,7 +12,7 @@ export function ForgotPasswordPage() { const [error, setError] = useState('') const [submitting, setSubmitting] = useState(false) - function handleSubmit(event: FormEvent) { + async function handleSubmit(event: FormEvent) { event.preventDefault() if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { setError('이메일 형식을 확인해 주세요.') @@ -18,7 +20,23 @@ export function ForgotPasswordPage() { } setError('') setSubmitting(true) - navigate('/email-sent', { state: { email } }) + try { + // 서버는 계정 존재 여부를 드러내지 않도록 항상 202를 반환한다 — 성공하면 무조건 안내 화면으로 이동한다. + await apiFetch('/auth/password-reset-requests', { + method: 'POST', + body: JSON.stringify({ email }), + skipAuthRetry: true, + }) + navigate('/email-sent', { state: { email } }) + } catch (requestError) { + setError( + requestError instanceof ApiError + ? getErrorMessage(requestError) + : '요청을 처리하지 못했습니다.', + ) + } finally { + setSubmitting(false) + } } return ( diff --git a/src/pages/ResetPasswordPage/ResetPasswordPage.test.tsx b/src/pages/ResetPasswordPage/ResetPasswordPage.test.tsx index 3a1e6d0..1df9043 100644 --- a/src/pages/ResetPasswordPage/ResetPasswordPage.test.tsx +++ b/src/pages/ResetPasswordPage/ResetPasswordPage.test.tsx @@ -1,12 +1,27 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router-dom' -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ResetPasswordPage } from './ResetPasswordPage' -function renderPage() { +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return new Response(JSON.stringify(body), { + status: 204, + headers: { 'Content-Type': 'application/json' }, + ...init, + }) +} + +function errorResponse(status: number, code: string, message: string) { + return jsonResponse( + { timestamp: '2026-08-07T00:00:00Z', status, code, message, path: '/api/v1/auth/password-resets', request_id: 'req-1', field_errors: [] }, + { status }, + ) +} + +function renderPage(initialPath = '/reset-password?token=valid-token') { render( - + } /> reset complete screen

} /> @@ -16,6 +31,14 @@ function renderPage() { ) } +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + describe('ResetPasswordPage', () => { it('shows a password strength meter once typing starts', async () => { const user = userEvent.setup() @@ -39,8 +62,13 @@ describe('ResetPasswordPage', () => { expect(screen.getByText('비밀번호가 일치하지 않습니다.')).toBeInTheDocument() }) - it('navigates to /reset-complete on a valid submit', async () => { + it('submits the token from the URL and navigates to /reset-complete on success', async () => { const user = userEvent.setup() + let requestBody: string | undefined + vi.mocked(fetch).mockImplementation((_input, init) => { + requestBody = init?.body as string | undefined + return Promise.resolve(new Response(null, { status: 204 })) + }) renderPage() await user.type(screen.getByLabelText('새 비밀번호'), 'password123') @@ -48,6 +76,33 @@ describe('ResetPasswordPage', () => { await user.click(screen.getByRole('button', { name: '비밀번호 변경' })) expect(await screen.findByText('reset complete screen')).toBeInTheDocument() + expect(JSON.parse(requestBody!)).toEqual({ token: 'valid-token', new_password: 'password123' }) + }) + + it('shows an error and does not navigate when the token is missing from the URL', async () => { + const user = userEvent.setup() + renderPage('/reset-password') + + await user.type(screen.getByLabelText('새 비밀번호'), 'password123') + await user.type(screen.getByLabelText('비밀번호 확인'), 'password123') + await user.click(screen.getByRole('button', { name: '비밀번호 변경' })) + + expect(screen.getByText('재설정 링크가 올바르지 않습니다. 새 링크를 요청해 주세요.')).toBeInTheDocument() + expect(fetch).not.toHaveBeenCalled() + }) + + it('shows the server error when the token is invalid or expired', async () => { + const user = userEvent.setup() + vi.mocked(fetch).mockResolvedValue( + errorResponse(400, 'INVALID_PASSWORD_RESET_TOKEN', '재설정 링크가 만료되었습니다.'), + ) + renderPage() + + await user.type(screen.getByLabelText('새 비밀번호'), 'password123') + await user.type(screen.getByLabelText('비밀번호 확인'), 'password123') + await user.click(screen.getByRole('button', { name: '비밀번호 변경' })) + + expect(await screen.findByText('재설정 링크가 만료되었습니다.')).toBeInTheDocument() }) it('links the expired-link recovery note back to /forgot-password', () => { diff --git a/src/pages/ResetPasswordPage/ResetPasswordPage.tsx b/src/pages/ResetPasswordPage/ResetPasswordPage.tsx index 8730d01..c0ed31f 100644 --- a/src/pages/ResetPasswordPage/ResetPasswordPage.tsx +++ b/src/pages/ResetPasswordPage/ResetPasswordPage.tsx @@ -1,5 +1,7 @@ import { useState, type FormEvent } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { Link, useNavigate, useSearchParams } from 'react-router-dom' +import { apiFetch } from '../../api/client' +import { ApiError, getErrorMessage } from '../../api/errors' import { Button } from '../../components/ui/Button/Button' import { EyeIcon, EyeOffIcon } from '../../components/ui/icons/EyeIcons' import { LockIcon } from '../../components/ui/icons/FieldIcons' @@ -8,6 +10,8 @@ import styles from './ResetPasswordPage.module.css' export function ResetPasswordPage() { const navigate = useNavigate() + const [searchParams] = useSearchParams() + const token = searchParams.get('token') const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [showPassword, setShowPassword] = useState(false) @@ -17,7 +21,7 @@ export function ResetPasswordPage() { const passwordStrength = getPasswordStrength(password) - function handleSubmit(event: FormEvent) { + async function handleSubmit(event: FormEvent) { event.preventDefault() if (password.length < 8) { setError('영문과 숫자를 포함해 8자 이상 입력해 주세요.') @@ -27,9 +31,28 @@ export function ResetPasswordPage() { setError('비밀번호가 일치하지 않습니다.') return } + if (!token) { + setError('재설정 링크가 올바르지 않습니다. 새 링크를 요청해 주세요.') + return + } setError('') setSubmitting(true) - navigate('/reset-complete') + try { + await apiFetch('/auth/password-resets', { + method: 'POST', + body: JSON.stringify({ token, new_password: password }), + skipAuthRetry: true, + }) + navigate('/reset-complete') + } catch (requestError) { + setError( + requestError instanceof ApiError + ? getErrorMessage(requestError) + : '비밀번호를 변경하지 못했습니다.', + ) + } finally { + setSubmitting(false) + } } return (