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 (