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
23 changes: 23 additions & 0 deletions src/api/profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { apiFetch } from './client'

// fowoco/server ProfileResponse (GET/PATCH /api/v1/auth/me/profile) 그대로.
export interface ProfileResponse {
display_name: string
phone: string | null
}

export interface UpdateProfileRequest {
display_name: string
phone: string | null
}

export function fetchMyProfile() {
return apiFetch<ProfileResponse>('/auth/me/profile')
}

export function updateMyProfile(body: UpdateProfileRequest) {
return apiFetch<ProfileResponse>('/auth/me/profile', {
method: 'PATCH',
body: JSON.stringify(body),
})
}
8 changes: 7 additions & 1 deletion src/components/layout/HeaderActions/HeaderActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HeaderActions } from './HeaderActions'
import { getSafeNotificationRoute } from './notificationPresentation'

const USER = { name: '김민지', email: 'kim@example.com', workplace: '한빛정밀', role: 'HR' }
const USER = {
name: '김민지',
phone: null,
email: 'kim@example.com',
workplace: '한빛정밀',
role: 'HR',
}
const NOTIFICATIONS = {
items: [
{
Expand Down
10 changes: 8 additions & 2 deletions src/pages/CaseDetailPage/CaseDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,13 @@ describe('CaseDetailPage', () => {
it('does not offer the response review action to a viewer', async () => {
const user = userEvent.setup()
useAuthStore.setState({
user: { name: 'viewer', email: 'viewer@example.com', workplace: 'FOWOCO', role: 'VIEWER' },
user: {
name: 'viewer',
phone: null,
email: 'viewer@example.com',
workplace: 'FOWOCO',
role: 'VIEWER',
},
status: 'ready',
})
mockTaskAndActivities(
Expand Down Expand Up @@ -739,7 +745,7 @@ describe('CaseDetailPage', () => {
it('adopts a submitted file as an official worker document', async () => {
const user = userEvent.setup()
useAuthStore.setState({
user: { name: 'hr', email: 'hr@example.com', workplace: 'FOWOCO', role: 'HR' },
user: { name: 'hr', phone: null, email: 'hr@example.com', workplace: 'FOWOCO', role: 'HR' },
status: 'ready',
})
mockTaskAndActivities(
Expand Down
10 changes: 8 additions & 2 deletions src/pages/ProfilePage/CompanySettingsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ function renderPanel() {

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

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

Expand Down
83 changes: 66 additions & 17 deletions src/pages/ProfilePage/ProfilePage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,34 @@
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createMemoryRouter, RouterProvider } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '../../store/authStore'
import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport'
import { useToastStore } from '../../store/toastStore'
import { ProfilePage } from './ProfilePage'

// fowoco/server ProfileResponse (GET/PATCH /api/v1/auth/me/profile) 그대로.
const PROFILE = { display_name: '김민지 HR', phone: '010-0000-1234' }

const SETTINGS = {
approval_policy: 'ADMIN_OR_HR',
link_expiry_hours: 72,
evidence_rules: { RECONTRACT: ['DOCUMENT'] },
file_retention_days: 365,
ai_log_retention_days: 90,
audit_visibility: 'ADMIN_ONLY',
version: 3,
}

const MEMBERS = { items: [] }

function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}

function renderPage() {
const router = createMemoryRouter(
[
Expand All @@ -30,27 +52,40 @@ function renderPage() {

beforeEach(() => {
useToastStore.setState({ toasts: [] })
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.includes('/company-members')) return jsonResponse(MEMBERS)
if (url.includes('/settings')) return jsonResponse(SETTINGS)
if (url.includes('/auth/me/profile') && init?.method === 'PATCH') {
return jsonResponse({ ...PROFILE, ...JSON.parse(init.body as string) })
}
if (url.includes('/auth/me/profile')) return jsonResponse(PROFILE)
return Promise.reject(new Error(`Unexpected request: ${url}`))
}),
)
})

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

describe('ProfilePage', () => {
it('renders the profile summary and read-only fields', () => {
it('renders the profile summary and editable fields fetched from the API', async () => {
renderPage()

expect(screen.getByRole('heading', { name: '설정' })).toBeInTheDocument()
expect(screen.getByText('김민지 HR')).toBeInTheDocument()
expect((await screen.findAllByText('김민지 HR')).length).toBeGreaterThan(0)
expect(screen.getByText('010-0000-1234')).toBeInTheDocument()
expect(screen.getByText('hr.demo@fowoco.example')).toBeInTheDocument()
expect(screen.getByText('체류·문서 운영')).toBeInTheDocument()
})

it("shows the real logged-in user's identity instead of the fixture persona", () => {
it("shows the real logged-in user's identity instead of the fixture persona", async () => {
useAuthStore.setState({
user: {
name: 'demo.admin',
phone: null,
email: 'demo.admin@example.com',
workplace: 'FOWOCO Demo Company',
role: 'ADMIN',
Expand All @@ -59,59 +94,69 @@ describe('ProfilePage', () => {
})
renderPage()

expect(screen.getAllByText('demo.admin').length).toBeGreaterThan(0)
await waitFor(() => 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 () => {
it('edits and saves the editable fields, persisting through the API', async () => {
const user = userEvent.setup()
renderPage()

await screen.findAllByText('김민지 HR')
await user.click(screen.getByRole('button', { name: '프로필 수정' }))
const displayNameInput = screen.getByLabelText('표시 이름')
await user.clear(displayNameInput)
await user.type(displayNameInput, '김민지 매니저')
await user.click(screen.getByRole('button', { name: '저장' }))

expect(screen.getByText('김민지 매니저')).toBeInTheDocument()
expect((await screen.findAllByText('김민지 매니저')).length).toBeGreaterThan(0)
expect(screen.getByText('프로필을 저장했습니다.')).toBeInTheDocument()

const patchCall = vi.mocked(fetch).mock.calls.find(([, init]) => init?.method === 'PATCH')
expect(patchCall).toBeTruthy()
expect(JSON.parse(String(patchCall?.[1]?.body))).toEqual({
display_name: '김민지 매니저',
phone: '010-0000-1234',
})
})

it('discards edits when cancelled', async () => {
const user = userEvent.setup()
renderPage()

await screen.findAllByText('김민지 HR')
await user.click(screen.getByRole('button', { name: '프로필 수정' }))
const displayNameInput = screen.getByLabelText('표시 이름')
await user.clear(displayNameInput)
await user.type(displayNameInput, '지워질 이름')
await user.click(screen.getByRole('button', { name: '취소' }))

expect(screen.getByText('김민지 HR')).toBeInTheDocument()
expect(screen.getAllByText('김민지 HR')[0]).toBeInTheDocument()
expect(screen.queryByText('지워질 이름')).not.toBeInTheDocument()
})

it('blocks saving when name is cleared and shows a validation error', async () => {
it('blocks saving when display name is cleared and shows a validation error', async () => {
const user = userEvent.setup()
renderPage()

await screen.findAllByText('김민지 HR')
await user.click(screen.getByRole('button', { name: '프로필 수정' }))
await user.clear(screen.getByLabelText('이름'))
await user.clear(screen.getByLabelText('표시 이름'))
await user.click(screen.getByRole('button', { name: '저장' }))

expect(screen.getByText('이름을 입력해 주세요.')).toBeInTheDocument()
expect(screen.getByText('표시 이름을 입력해 주세요.')).toBeInTheDocument()
// 저장 실패했으니 편집 모드가 유지돼야 한다.
expect(screen.getByRole('button', { name: '취소' })).toBeInTheDocument()
})

it('rejects a name made up of only digits', async () => {
it('rejects a display name made up of only digits', async () => {
const user = userEvent.setup()
renderPage()

await screen.findAllByText('김민지 HR')
await user.click(screen.getByRole('button', { name: '프로필 수정' }))
const nameInput = screen.getByLabelText('이름')
const nameInput = screen.getByLabelText('표시 이름')
await user.clear(nameInput)
await user.type(nameInput, '12345')
await user.click(screen.getByRole('button', { name: '저장' }))
Expand All @@ -123,6 +168,7 @@ describe('ProfilePage', () => {
const user = userEvent.setup()
renderPage()

await screen.findAllByText('김민지 HR')
await user.click(screen.getByRole('button', { name: '이메일 변경 요청 →' }))

expect(screen.getByText('이메일 변경 요청을 관리자에게 전달했습니다.')).toBeInTheDocument()
Expand Down Expand Up @@ -175,6 +221,7 @@ describe('ProfilePage', () => {
const user = userEvent.setup()
renderPage()

await screen.findAllByText('김민지 HR')
await user.click(screen.getByRole('button', { name: '프로필 수정' }))
await user.type(screen.getByLabelText('연락처'), '9')
await user.click(screen.getByRole('button', { name: '비밀번호 변경' }))
Expand All @@ -194,6 +241,7 @@ describe('ProfilePage', () => {
const user = userEvent.setup()
renderPage()

await screen.findAllByText('김민지 HR')
await user.click(screen.getByRole('button', { name: '프로필 수정' }))
await user.type(screen.getByLabelText('연락처'), '9')
await user.click(screen.getByRole('button', { name: '비밀번호 변경' }))
Expand All @@ -206,6 +254,7 @@ describe('ProfilePage', () => {
const user = userEvent.setup()
renderPage()

await screen.findAllByText('김민지 HR')
await user.click(screen.getByRole('button', { name: '프로필 수정' }))
await user.type(screen.getByLabelText('연락처'), '9')
await user.click(screen.getByRole('button', { name: '비밀번호 변경' }))
Expand Down
Loading