File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ProjectsPage from './ProjectsPage'
import { withCodes } from '../codes/fixtures'
import type { ContactLog, DashboardOrgRow, OrgReport, ProjectRow } from '../api/client'
const mocks = vi.hoisted(() => ({
getProjects: vi.fn(),
getContactLogs: vi.fn(),
addContactLog: vi.fn(),
updateContactLog: vi.fn(),
deleteContactLog: vi.fn(),
getReports: vi.fn(),
uploadReport: vi.fn(),
deleteReport: vi.fn(),
}))
vi.mock('../api/client', async (importOriginal) => ({
...(await importOriginal<typeof import('../api/client')>()),
...mocks,
}))
function orgRow(overrides: Partial<DashboardOrgRow> = {}): DashboardOrgRow {
return {
id: 1,
orgNo: '001_00',
orgName: '국제방송교류재단',
stage: 5,
hasChannel: true,
lawyerName: '권홍철',
mjName: '이연경',
lawyerAssignedDate: null,
reviewTotal: 100,
reviewDone: 40,
processTotal: 10,
processDone: 2,
stageChangedAt: null,
lastChangedAt: null,
reCount: 0,
...overrides,
}
}
function row(overrides: Partial<ProjectRow> = {}): ProjectRow {
return {
org: orgRow(),
lastContactedOn: null,
contactCount: 0,
reportCount: 0,
...overrides,
}
}
function log(overrides: Partial<ContactLog> = {}): ContactLog {
return {
id: 1,
orgId: 1,
contactedOn: '2026-07-20',
method: '전화',
summary: '신청 확인 통화',
author: 'admin',
createdAt: Date.now(),
...overrides,
}
}
function report(overrides: Partial<OrgReport> = {}): OrgReport {
return {
id: 1,
orgId: 1,
fileName: '최종보고서.pdf',
contentType: 'application/pdf',
byteSize: 2048,
uploadedBy: 'admin',
uploadedAt: Date.now(),
...overrides,
}
}
async function openPanel() {
render(withCodes(<ProjectsPage onOpenOrg={vi.fn()} />))
await screen.findByTestId('project-table')
fireEvent.click(screen.getByRole('button', { name: '연락·보고서' }))
await waitFor(() => expect(mocks.getContactLogs).toHaveBeenCalledWith(1))
}
describe('ProjectsPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getProjects.mockResolvedValue([row()])
mocks.getContactLogs.mockResolvedValue([])
mocks.getReports.mockResolvedValue([])
})
it('기관 현황을 표로 보여준다', async () => {
mocks.getProjects.mockResolvedValue([
row({ lastContactedOn: '2026-07-20', contactCount: 3, reportCount: 1 }),
])
render(withCodes(<ProjectsPage onOpenOrg={vi.fn()} />))
const table = within(await screen.findByTestId('project-table'))
expect(table.getByText('국제방송교류재단')).toBeTruthy()
expect(table.getByText('5. 법률검토(권리확인)')).toBeTruthy()
expect(table.getByText('2026-07-20')).toBeTruthy()
expect(table.getByText('(3)')).toBeTruthy()
})
it('기관명을 클릭하면 기관관리로 넘긴다', async () => {
const onOpenOrg = vi.fn()
render(withCodes(<ProjectsPage onOpenOrg={onOpenOrg} />))
fireEvent.click(await screen.findByRole('button', { name: '국제방송교류재단' }))
expect(onOpenOrg).toHaveBeenCalledWith(1)
})
it('검색과 연락 이력 필터로 목록을 거른다', async () => {
mocks.getProjects.mockResolvedValue([
row(),
row({ org: orgRow({ id: 2, orgNo: '002_00', orgName: '세종학당재단' }), contactCount: 2 }),
])
render(withCodes(<ProjectsPage onOpenOrg={vi.fn()} />))
await screen.findByTestId('project-table')
fireEvent.change(screen.getByLabelText('기관명/기관코드 검색'), { target: { value: '세종' } })
expect(within(screen.getByTestId('project-table')).queryByText('국제방송교류재단')).toBeNull()
fireEvent.change(screen.getByLabelText('기관명/기관코드 검색'), { target: { value: '' } })
fireEvent.change(screen.getByLabelText('연락 이력'), { target: { value: 'none' } })
expect(within(screen.getByTestId('project-table')).queryByText('세종학당재단')).toBeNull()
expect(within(screen.getByTestId('project-table')).getByText('국제방송교류재단')).toBeTruthy()
})
it('연락·보고서 창을 열면 이력과 보고서를 함께 읽는다', async () => {
mocks.getContactLogs.mockResolvedValue([log()])
mocks.getReports.mockResolvedValue([report()])
await openPanel()
expect(screen.getByText('신청 확인 통화')).toBeTruthy()
expect(screen.getByText('최종보고서.pdf')).toBeTruthy()
expect(screen.getByText(/2026-07-20 · 전화 · admin/)).toBeTruthy()
})
it('연락 이력을 등록하면 서버가 준 목록으로 갱신한다', async () => {
mocks.addContactLog.mockResolvedValue([log({ summary: '자료 요청' })])
await openPanel()
fireEvent.change(screen.getByLabelText('연락일'), { target: { value: '2026-07-25' } })
fireEvent.change(screen.getByLabelText('연락 방법'), { target: { value: '메일' } })
fireEvent.change(screen.getByLabelText('연락 내용'), { target: { value: '자료 요청' } })
fireEvent.click(screen.getByRole('button', { name: '등록' }))
await waitFor(() =>
expect(mocks.addContactLog).toHaveBeenCalledWith(1, {
contactedOn: '2026-07-25',
method: '메일',
summary: '자료 요청',
}),
)
expect(await screen.findByText('자료 요청')).toBeTruthy()
// 목록 숫자(최근 연락)도 다시 읽는다
expect(mocks.getProjects).toHaveBeenCalledTimes(2)
})
it('내용이 비면 등록 버튼이 잠긴다', async () => {
await openPanel()
expect(screen.getByRole('button', { name: '등록' })).toBeDisabled()
})
it('수정을 누르면 입력칸에 값이 올라오고 저장하면 updateContactLog를 부른다', async () => {
mocks.getContactLogs.mockResolvedValue([log({ id: 9, summary: '원래 내용' })])
mocks.updateContactLog.mockResolvedValue([log({ id: 9, summary: '고친 내용' })])
await openPanel()
fireEvent.click(screen.getByRole('button', { name: '수정' }))
expect((screen.getByLabelText('연락 내용') as HTMLInputElement).value).toBe('원래 내용')
fireEvent.change(screen.getByLabelText('연락 내용'), { target: { value: '고친 내용' } })
fireEvent.click(screen.getByRole('button', { name: '수정 저장' }))
await waitFor(() =>
expect(mocks.updateContactLog).toHaveBeenCalledWith(1, 9, {
contactedOn: '2026-07-20',
method: '전화',
summary: '고친 내용',
}),
)
})
it('연락 이력 삭제는 확인을 물어본다', async () => {
mocks.getContactLogs.mockResolvedValue([log({ id: 9 })])
mocks.deleteContactLog.mockResolvedValue(undefined)
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
await openPanel()
fireEvent.click(screen.getByRole('button', { name: '삭제' }))
expect(confirmSpy).toHaveBeenCalled()
await waitFor(() => expect(mocks.deleteContactLog).toHaveBeenCalledWith(1, 9))
confirmSpy.mockRestore()
})
it('보고서를 고르면 업로드하고 목록을 갱신한다', async () => {
mocks.uploadReport.mockResolvedValue([report({ fileName: '2차보고서.pdf' })])
await openPanel()
const file = new File(['내용'], '2차보고서.pdf', { type: 'application/pdf' })
fireEvent.change(screen.getByLabelText('보고서 파일'), { target: { files: [file] } })
await waitFor(() => expect(mocks.uploadReport).toHaveBeenCalledWith(1, file))
expect(await screen.findByText('2차보고서.pdf')).toBeTruthy()
})
it('보고서는 다운로드 링크로 걸린다', async () => {
mocks.getReports.mockResolvedValue([report({ id: 7 })])
await openPanel()
expect(screen.getByRole('link', { name: '최종보고서.pdf' })).toHaveAttribute(
'href',
'/api/orgs/1/reports/7',
)
})
})