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
42 changes: 27 additions & 15 deletions src/components/layout/HeaderActions/HeaderActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HeaderActions } from './HeaderActions'
import { getSafeNotificationRoute } from './notificationPresentation'

const USER = { name: '김민지', workplace: '한빛정밀', role: 'HR' }
const USER = { name: '김민지', email: 'kim@example.com', workplace: '한빛정밀', role: 'HR' }
const NOTIFICATIONS = {
items: [
{
Expand Down Expand Up @@ -94,23 +94,31 @@ describe('HeaderActions', () => {
await user.click(screen.getByRole('menuitem', { name: /체류연장 요청문 승인이 필요합니다/ }))

await waitFor(() => expect(router.state.location.pathname).toBe('/tasks/task-1'))
expect(vi.mocked(fetch).mock.calls.some(([url, init]) =>
String(url).includes('/notifications/n1/read') && init?.method === 'POST',
)).toBe(true)
expect(
vi
.mocked(fetch)
.mock.calls.some(
([url, init]) =>
String(url).includes('/notifications/n1/read') && init?.method === 'POST',
),
).toBe(true)
})

it('shows an error and keeps the panel open when read processing fails', async () => {
vi.mocked(fetch).mockImplementation(async (_input, init) => {
if (init?.method === 'POST') {
return jsonResponse({
timestamp: '2026-08-10T01:00:00Z',
status: 500,
code: 'INTERNAL_SERVER_ERROR',
message: 'failed',
path: '/api/v1/notifications/n1/read',
request_id: 'request-1',
field_errors: [],
}, 500)
return jsonResponse(
{
timestamp: '2026-08-10T01:00:00Z',
status: 500,
code: 'INTERNAL_SERVER_ERROR',
message: 'failed',
path: '/api/v1/notifications/n1/read',
request_id: 'request-1',
field_errors: [],
},
500,
)
}
return jsonResponse(NOTIFICATIONS)
})
Expand All @@ -120,7 +128,9 @@ describe('HeaderActions', () => {
await user.click(await screen.findByLabelText('알림 1건 안 읽음'))
await user.click(screen.getByRole('menuitem', { name: /체류연장 요청문 승인이 필요합니다/ }))

expect(await screen.findByText('일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.')).toBeInTheDocument()
expect(
await screen.findByText('일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'),
).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/dashboard')
expect(screen.getByRole('menu', { name: '알림 목록' })).toBeInTheDocument()
})
Expand Down Expand Up @@ -156,7 +166,9 @@ describe('HeaderActions', () => {

describe('getSafeNotificationRoute', () => {
it('keeps expected internal routes and rejects external or unexpected routes', () => {
expect(getSafeNotificationRoute('/documents/document-1?tab=ocr')).toBe('/documents/document-1?tab=ocr')
expect(getSafeNotificationRoute('/documents/document-1?tab=ocr')).toBe(
'/documents/document-1?tab=ocr',
)
expect(getSafeNotificationRoute('https://example.com')).toBe('/dashboard')
expect(getSafeNotificationRoute('//example.com/tasks/1')).toBe('/dashboard')
expect(getSafeNotificationRoute('/profile')).toBe('/dashboard')
Expand Down
4 changes: 2 additions & 2 deletions src/pages/CaseDetailPage/CaseDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,7 @@ describe('CaseDetailPage', () => {
it('does not offer the response review action to a viewer', async () => {
const user = userEvent.setup()
useAuthStore.setState({
user: { name: 'viewer', workplace: 'FOWOCO', role: 'VIEWER' },
user: { name: 'viewer', email: 'viewer@example.com', workplace: 'FOWOCO', role: 'VIEWER' },
status: 'ready',
})
mockTaskAndActivities(
Expand Down Expand Up @@ -739,7 +739,7 @@ describe('CaseDetailPage', () => {
it('adopts a submitted file as an official worker document', async () => {
const user = userEvent.setup()
useAuthStore.setState({
user: { name: 'hr', workplace: 'FOWOCO', role: 'HR' },
user: { name: 'hr', email: 'hr@example.com', workplace: 'FOWOCO', role: 'HR' },
status: 'ready',
})
mockTaskAndActivities(
Expand Down
56 changes: 32 additions & 24 deletions src/pages/ProfilePage/CompanySettingsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ function renderPanel() {

beforeEach(() => {
useAuthStore.setState({
user: { name: 'admin', workplace: 'FOWOCO', role: 'ADMIN' },
user: { name: 'admin', email: 'admin@example.com', workplace: 'FOWOCO', role: 'ADMIN' },
status: 'ready',
})
useToastStore.setState({ toasts: [] })
Expand Down Expand Up @@ -108,7 +108,9 @@ describe('CompanySettingsPanel', () => {
})

it('renders HR and VIEWER settings as read-only', async () => {
useAuthStore.setState({ user: { name: 'hr', workplace: 'FOWOCO', role: 'HR' } })
useAuthStore.setState({
user: { name: 'hr', email: 'hr@example.com', workplace: 'FOWOCO', role: 'HR' },
})
renderPanel()

expect(await screen.findByText('HR 조회 전용')).toBeInTheDocument()
Expand All @@ -122,21 +124,22 @@ describe('CompanySettingsPanel', () => {
const url = String(input)
if (url.includes('/company-members')) return jsonResponse(MEMBERS)
if (init?.method === 'PATCH') {
return jsonResponse({
timestamp: '2026-08-10T01:00:00Z',
status: 409,
code: 'CONCURRENT_MODIFICATION',
message: 'conflict',
path: '/api/v1/settings',
request_id: 'request-1',
field_errors: [],
}, 409)
return jsonResponse(
{
timestamp: '2026-08-10T01:00:00Z',
status: 409,
code: 'CONCURRENT_MODIFICATION',
message: 'conflict',
path: '/api/v1/settings',
request_id: 'request-1',
field_errors: [],
},
409,
)
}
settingsGetCount += 1
return jsonResponse(
settingsGetCount === 1
? SETTINGS
: { ...SETTINGS, link_expiry_hours: 24, version: 4 },
settingsGetCount === 1 ? SETTINGS : { ...SETTINGS, link_expiry_hours: 24, version: 4 },
)
})
const user = userEvent.setup()
Expand All @@ -153,20 +156,25 @@ describe('CompanySettingsPanel', () => {
it('shows a recoverable error when settings cannot be loaded', async () => {
vi.mocked(fetch).mockImplementation(async (input) => {
if (String(input).includes('/company-members')) return jsonResponse(MEMBERS)
return jsonResponse({
timestamp: '2026-08-10T01:00:00Z',
status: 500,
code: 'INTERNAL_SERVER_ERROR',
message: 'failed',
path: '/api/v1/settings',
request_id: 'request-1',
field_errors: [],
}, 500)
return jsonResponse(
{
timestamp: '2026-08-10T01:00:00Z',
status: 500,
code: 'INTERNAL_SERVER_ERROR',
message: 'failed',
path: '/api/v1/settings',
request_id: 'request-1',
field_errors: [],
},
500,
)
})

renderPanel()

expect(await screen.findByText('일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.')).toBeInTheDocument()
expect(
await screen.findByText('일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: '다시 시도' })).toBeInTheDocument()
})
})
29 changes: 27 additions & 2 deletions src/pages/ProfilePage/ProfilePage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createMemoryRouter, RouterProvider } from 'react-router-dom'
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { useAuthStore } from '../../store/authStore'
import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport'
import { useToastStore } from '../../store/toastStore'
import { ProfilePage } from './ProfilePage'
Expand Down Expand Up @@ -31,6 +32,10 @@ beforeEach(() => {
useToastStore.setState({ toasts: [] })
})

afterEach(() => {
useAuthStore.setState({ user: null })
})

describe('ProfilePage', () => {
it('renders the profile summary and read-only fields', () => {
renderPage()
Expand All @@ -42,6 +47,24 @@ describe('ProfilePage', () => {
expect(screen.getByText('체류·문서 운영')).toBeInTheDocument()
})

it("shows the real logged-in user's identity instead of the fixture persona", () => {
useAuthStore.setState({
user: {
name: 'demo.admin',
email: 'demo.admin@example.com',
workplace: 'FOWOCO Demo Company',
role: 'ADMIN',
},
status: 'ready',
})
renderPage()

expect(screen.getAllByText('demo.admin').length).toBeGreaterThan(0)
expect(screen.getAllByText(/demo\.admin@example\.com/).length).toBeGreaterThan(0)
expect(screen.getByText('FOWOCO Demo Company')).toBeInTheDocument()
expect(screen.queryByText('hr.demo@fowoco.example')).not.toBeInTheDocument()
})

it('edits and saves the editable fields', async () => {
const user = userEvent.setup()
renderPage()
Expand Down Expand Up @@ -156,7 +179,9 @@ describe('ProfilePage', () => {
await user.type(screen.getByLabelText('연락처'), '9')
await user.click(screen.getByRole('button', { name: '비밀번호 변경' }))

expect(screen.getByRole('dialog', { name: '저장하지 않은 변경사항이 있습니다.' })).toBeInTheDocument()
expect(
screen.getByRole('dialog', { name: '저장하지 않은 변경사항이 있습니다.' }),
).toBeInTheDocument()
expect(screen.getByText(/변경사항 1개/)).toBeInTheDocument()

await user.click(screen.getByRole('button', { name: '계속 수정' }))
Expand Down
52 changes: 39 additions & 13 deletions src/pages/ProfilePage/ProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Button } from '../../components/ui/Button/Button'
import { DetailRow } from '../../components/ui/DetailRow/DetailRow'
import { Modal } from '../../components/ui/Modal/Modal'
import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel'
import { useAuthStore } from '../../store/authStore'
import { useToastStore } from '../../store/toastStore'
import { CompanySettingsPanel } from './CompanySettingsPanel'
import {
Expand Down Expand Up @@ -49,20 +50,28 @@ function validateFields(input: EditableProfileFields): FieldErrors {
export function ProfilePage() {
const navigate = useNavigate()
const showToast = useToastStore((state) => state.showToast)
const user = useAuthStore((state) => state.user)

const [fields, setFields] = useState<EditableProfileFields>(INITIAL_PROFILE_FIELDS)
const [draft, setDraft] = useState<EditableProfileFields>(INITIAL_PROFILE_FIELDS)
const initialFields: EditableProfileFields = {
...INITIAL_PROFILE_FIELDS,
name: user?.name ?? INITIAL_PROFILE_FIELDS.name,
}
const [fields, setFields] = useState<EditableProfileFields>(initialFields)
const [draft, setDraft] = useState<EditableProfileFields>(initialFields)
const [editing, setEditing] = useState(false)
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
const [notificationPrefs, setNotificationPrefs] = useState(INITIAL_NOTIFICATION_PREFS)

const changedFieldCount = EDITABLE_FIELD_META.filter(({ key }) => draft[key] !== fields[key]).length
const changedFieldCount = EDITABLE_FIELD_META.filter(
({ key }) => draft[key] !== fields[key],
).length
const isDirty = editing && changedFieldCount > 0

// Figma "저장하지 않은 변경사항이 있습니다" 오버레이(node 1623:2530) — 편집 중 다른 화면으로
// 이동하려 하면 확인을 받는다.
const blocker = useBlocker(
({ currentLocation, nextLocation }) => isDirty && currentLocation.pathname !== nextLocation.pathname,
({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname,
)

function handleStartEdit() {
Expand Down Expand Up @@ -94,7 +103,9 @@ export function ProfilePage() {

function handleToggleNotification(id: string) {
setNotificationPrefs((prev) =>
prev.map((pref) => (pref.id === id && !pref.required ? { ...pref, enabled: !pref.enabled } : pref)),
prev.map((pref) =>
pref.id === id && !pref.required ? { ...pref, enabled: !pref.enabled } : pref,
),
)
}

Expand Down Expand Up @@ -133,16 +144,18 @@ export function ProfilePage() {

<div className={styles.summaryCard}>
<div className={styles.avatar} aria-hidden="true">
{PROFILE_SUMMARY.initial}
{fields.name.charAt(0) || PROFILE_SUMMARY.initial}
</div>
<div className={styles.summaryIdentity}>
<p className={styles.summaryName}>{fields.name}</p>
<p className={styles.summaryMeta}>
{PROFILE_SUMMARY.role} · {PROFILE_SUMMARY.email}
{user?.role ?? PROFILE_SUMMARY.role} · {user?.email ?? PROFILE_SUMMARY.email}
</p>
<div className={styles.summaryStatusRow}>
<StatusLabel tone="success">사용 중</StatusLabel>
<span className={styles.summaryCompany}>{PROFILE_SUMMARY.companyName}</span>
<span className={styles.summaryCompany}>
{user?.workplace ?? PROFILE_SUMMARY.companyName}
</span>
</div>
</div>
<div className={styles.summaryLastLogin}>
Expand Down Expand Up @@ -175,7 +188,9 @@ export function ProfilePage() {
className={styles.fieldInput}
value={draft[key]}
aria-label={label}
onChange={(event) => setDraft((prev) => ({ ...prev, [key]: event.target.value }))}
onChange={(event) =>
setDraft((prev) => ({ ...prev, [key]: event.target.value }))
}
/>
{fieldErrors[key] && <p className={styles.fieldError}>{fieldErrors[key]}</p>}
</>
Expand All @@ -191,8 +206,12 @@ export function ProfilePage() {
<span className={styles.fieldLabel}>로그인 이메일</span>
<span className={styles.fieldBadgeMuted}>본인 확인 필요</span>
</div>
<p className={styles.fieldValue}>{PROFILE_SUMMARY.email}</p>
<button type="button" className={styles.fieldLinkButton} onClick={handleRequestEmailChange}>
<p className={styles.fieldValue}>{user?.email ?? PROFILE_SUMMARY.email}</p>
<button
type="button"
className={styles.fieldLinkButton}
onClick={handleRequestEmailChange}
>
이메일 변경 요청 →
</button>
</div>
Expand Down Expand Up @@ -265,7 +284,10 @@ export function ProfilePage() {
<p className={styles.cardDescription}>계정 보호 상태와 최근 로그인 정보를 확인합니다.</p>
<hr className={styles.divider} />

<DetailRow label="계정 보호 상태" value={<StatusLabel tone="success">{SECURITY_INFO.accountStatus}</StatusLabel>} />
<DetailRow
label="계정 보호 상태"
value={<StatusLabel tone="success">{SECURITY_INFO.accountStatus}</StatusLabel>}
/>
<DetailRow label="비밀번호 변경" value={SECURITY_INFO.passwordChangedAt} />
<DetailRow label="로그인 기기" value={SECURITY_INFO.loginDeviceSummary} />

Expand Down Expand Up @@ -296,7 +318,11 @@ export function ProfilePage() {
변경사항 {changedFieldCount}개 · 입력값은 현재 편집 화면에 유지됩니다.
</p>
<div className={styles.blockerActions}>
<button type="button" className={styles.cardLinkButton} onClick={handleBlockerContinueEditing}>
<button
type="button"
className={styles.cardLinkButton}
onClick={handleBlockerContinueEditing}
>
계속 수정
</button>
<div className={styles.editActions}>
Expand Down
Loading