Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))

Expand Down
7 changes: 5 additions & 2 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,12 @@ export async function apiFetch<T>(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
}
45 changes: 43 additions & 2 deletions src/pages/ForgotPasswordPage/ForgotPasswordPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter initialEntries={['/forgot-password']}>
Expand All @@ -15,6 +30,14 @@ function renderPage() {
)
}

beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})

afterEach(() => {
vi.unstubAllGlobals()
})

describe('ForgotPasswordPage', () => {
it('renders the recovery form', () => {
renderPage()
Expand All @@ -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()
})
})
22 changes: 20 additions & 2 deletions src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -10,15 +12,31 @@ export function ForgotPasswordPage() {
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)

function handleSubmit(event: FormEvent<HTMLFormElement>) {
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError('이메일 형식을 확인해 주세요.')
return
}
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 (
Expand Down
63 changes: 59 additions & 4 deletions src/pages/ResetPasswordPage/ResetPasswordPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter initialEntries={['/reset-password']}>
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/reset-complete" element={<p>reset complete screen</p>} />
Expand All @@ -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()
Expand All @@ -39,15 +62,47 @@ 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')
await user.type(screen.getByLabelText('비밀번호 확인'), 'password123')
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', () => {
Expand Down
29 changes: 26 additions & 3 deletions src/pages/ResetPasswordPage/ResetPasswordPage.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
Expand All @@ -17,7 +21,7 @@ export function ResetPasswordPage() {

const passwordStrength = getPasswordStrength(password)

function handleSubmit(event: FormEvent<HTMLFormElement>) {
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (password.length < 8) {
setError('영문과 숫자를 포함해 8자 이상 입력해 주세요.')
Expand All @@ -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 (
Expand Down
Loading