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 } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ProcessBoard from './ProcessBoard'
import { withCodes } from '../codes/fixtures'
import type { Org, ProcessItem, ProcessPage } from '../api/client'
const mocks = vi.hoisted(() => ({
getProcessPage: vi.fn(),
getProcessItem: vi.fn(),
importProcess: vi.fn(),
updateProcessing: vi.fn(),
deleteProcessItem: vi.fn(),
bulkUpdateProcess: vi.fn(),
bulkDeleteProcess: vi.fn(),
}))
vi.mock('../api/client', async (importOriginal) => ({
...(await importOriginal<typeof import('../api/client')>()),
getProcessPage: mocks.getProcessPage,
getProcessItem: mocks.getProcessItem,
importProcess: mocks.importProcess,
updateProcessing: mocks.updateProcessing,
deleteProcessItem: mocks.deleteProcessItem,
bulkUpdateProcess: mocks.bulkUpdateProcess,
bulkDeleteProcess: mocks.bulkDeleteProcess,
}))
function org(overrides: Partial<Org> = {}): Org {
return {
id: 1,
orgNo: '001',
orgName: '한국출판문화산업진흥원',
status: 'ACTIVE',
applicant: null,
mj: null,
itn: null,
lawyer: null,
lawyerAssignedDate: null,
channelIdMj: 'chan-mj',
channelIdLaw: 'chan-law',
stage: null,
...overrides,
}
}
function item(overrides: Partial<ProcessItem> = {}): ProcessItem {
return {
id: 1,
orgId: 1,
seq: 1,
siteName: '한국출판문화산업진흥원',
category: '공지',
boardName: '공지사항',
postTitle: '2026년 사업 공고',
url: 'https://example.com/1',
description: null,
hasAttachment: 'N',
priorKoglType: '1유형',
contractDocs: null,
producedDate: '2026-01-01',
publishedDate: '2026-01-02',
reviewMajor: '만료 저작물',
reviewMinor: null,
reviewResult: '신유형 개방',
reviewKoglType: '1유형',
reviewAiType: null,
reviewOpinion: null,
reviewNote: null,
priorEvidence: null,
judgedKoglType: null,
judgedAiType: null,
finalOpinion: null,
judgmentBasis: null,
processStatus: null,
webEditedAt: null,
processedAt: null,
createdAt: 0,
updatedAt: 0,
...overrides,
}
}
function page(overrides: Partial<ProcessPage> = {}): ProcessPage {
return {
items: [item()],
total: 1,
done: 0,
page: 0,
size: 30,
...overrides,
}
}
beforeEach(() => {
mocks.getProcessPage.mockReset()
mocks.getProcessItem.mockReset()
mocks.importProcess.mockReset()
mocks.updateProcessing.mockReset()
mocks.deleteProcessItem.mockReset()
mocks.bulkUpdateProcess.mockReset()
mocks.bulkDeleteProcess.mockReset()
})
describe('ProcessBoard 목록', () => {
it('목록을 표로 보여주고 처리상태 배지가 나온다', async () => {
mocks.getProcessPage.mockResolvedValue(page())
render(withCodes(<ProcessBoard org={org()} />))
await waitFor(() => {
expect(screen.getByText('2026년 사업 공고')).toBeTruthy()
// "미처리"는 상태 필터 칩과 배지 둘 다에 나오므로 배지를 role로 구분해 확인한다.
expect(screen.getAllByText('미처리').length).toBeGreaterThanOrEqual(2)
})
expect(screen.getByText('총 1건 · 처리완료 0건')).toBeTruthy()
})
it('자료가 없으면 안내 문구를 보여준다', async () => {
mocks.getProcessPage.mockResolvedValue(page({ items: [], total: 0 }))
render(withCodes(<ProcessBoard org={org()} />))
await waitFor(() => {
expect(
screen.getByText('업로드된 권리처리 자료가 없습니다. 엑셀 업로드로 시작하세요.'),
).toBeTruthy()
})
})
it('상태 칩을 누르면 status 파라미터로 다시 조회한다', async () => {
mocks.getProcessPage.mockResolvedValue(page())
render(withCodes(<ProcessBoard org={org()} />))
await waitFor(() =>
expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
keyword: undefined,
status: undefined,
page: 0,
size: 30,
}),
)
fireEvent.click(screen.getByRole('button', { name: '처리완료' }))
await waitFor(() =>
expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
keyword: undefined,
status: 'DONE',
page: 0,
size: 30,
}),
)
fireEvent.click(screen.getByRole('button', { name: '미처리' }))
await waitFor(() =>
expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
keyword: undefined,
status: 'PENDING',
page: 0,
size: 30,
}),
)
})
it('검색어를 입력하고 검색을 누르면 keyword로 다시 조회한다', async () => {
mocks.getProcessPage.mockResolvedValue(page())
render(withCodes(<ProcessBoard org={org()} />))
await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalledTimes(1))
fireEvent.change(screen.getByLabelText('검색어'), { target: { value: '출판' } })
fireEvent.click(screen.getByRole('button', { name: '검색' }))
await waitFor(() =>
expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
keyword: '출판',
status: undefined,
page: 0,
size: 30,
}),
)
})
it('엑셀 업로드를 하면 importProcess를 호출하고 결과를 보여준다', async () => {
mocks.getProcessPage.mockResolvedValue(page())
mocks.importProcess.mockResolvedValue({ created: 3, updated: 2, total: 5 })
const { container } = render(withCodes(<ProcessBoard org={org()} />))
await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalledTimes(1))
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const file = new File(['dummy'], 'process.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => expect(mocks.importProcess).toHaveBeenCalledWith(1, file))
await waitFor(() => {
expect(screen.getByText('신규 3 · 갱신 2 · 전체 5')).toBeTruthy()
})
})
it('처리완료 건은 수정 버튼이, 미처리 건은 처리등록 버튼만 보인다', async () => {
mocks.getProcessPage.mockResolvedValue(page({ items: [item({ processStatus: '처리완료' })] }))
render(withCodes(<ProcessBoard org={org()} />))
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
expect(screen.getByRole('button', { name: '수정' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '처리등록' })).toBeNull()
// 개별 행 삭제는 없앴다(상단 [선택 삭제] 또는 상세 화면에서 지운다)
expect(screen.queryByRole('button', { name: '삭제' })).toBeNull()
})
it('행을 체크하고 [선택 삭제]를 누르면 확인 후 일괄 삭제한다', async () => {
mocks.getProcessPage.mockResolvedValue(page({ items: [item({ processStatus: '처리완료' })] }))
mocks.bulkDeleteProcess.mockResolvedValue({ deleted: 1 })
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
render(withCodes(<ProcessBoard org={org()} />))
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
fireEvent.click(screen.getByLabelText('1번 선택'))
fireEvent.click(screen.getByRole('button', { name: '선택 삭제' }))
expect(confirmSpy).toHaveBeenCalled()
await waitFor(() => expect(mocks.bulkDeleteProcess).toHaveBeenCalledWith(1, [1]))
confirmSpy.mockRestore()
})
it('체크한 건의 처리상태와 최종의견을 일괄 반영한다', async () => {
mocks.getProcessPage.mockResolvedValue(page())
mocks.bulkUpdateProcess.mockResolvedValue({ updated: 1 })
render(withCodes(<ProcessBoard org={org()} />))
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
fireEvent.click(screen.getByLabelText('1번 선택'))
fireEvent.change(screen.getByLabelText('일괄 처리상태'), { target: { value: '처리완료' } })
fireEvent.change(screen.getByLabelText('일괄 최종의견'), { target: { value: '일괄 처리' } })
fireEvent.click(screen.getByRole('button', { name: '일괄 등록' }))
await waitFor(() =>
expect(mocks.bulkUpdateProcess).toHaveBeenCalledWith(1, {
ids: [1],
processStatus: '처리완료',
finalOpinion: '일괄 처리',
judgedKoglType: undefined,
}),
)
expect(await screen.findByText('1건에 반영했습니다.')).toBeTruthy()
})
it('상세검색 3칸은 열별 조건으로 함께 넘어간다', async () => {
mocks.getProcessPage.mockResolvedValue(page())
render(withCodes(<ProcessBoard org={org()} />))
await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: '상세검색' }))
fireEvent.change(screen.getByLabelText('사이트명'), { target: { value: '누리집' } })
fireEvent.change(screen.getByLabelText('게시판명'), { target: { value: '공고' } })
fireEvent.change(screen.getByLabelText('게시물 제목'), { target: { value: '사업' } })
fireEvent.click(screen.getByRole('button', { name: '검색' }))
await waitFor(() =>
expect(mocks.getProcessPage).toHaveBeenLastCalledWith(1, {
keyword: undefined,
site: '누리집',
board: '공고',
title: '사업',
status: undefined,
page: 0,
size: 30,
}),
)
})
it('다음 페이지 버튼을 누르면 page+1로 다시 조회한다', async () => {
mocks.getProcessPage.mockResolvedValue(page({ total: 31 }))
render(withCodes(<ProcessBoard org={org()} />))
await waitFor(() =>
expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
keyword: undefined,
status: undefined,
page: 0,
size: 30,
}),
)
fireEvent.click(screen.getByRole('button', { name: '›' }))
await waitFor(() =>
expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
keyword: undefined,
status: undefined,
page: 1,
size: 30,
}),
)
})
})
describe('ProcessBoard 상세', () => {
it('처리등록을 누르면 상세 화면이 열리고 조회한 값이 채워진다', async () => {
mocks.getProcessPage.mockResolvedValue(page())
mocks.getProcessItem.mockResolvedValue(item({ finalOpinion: '검토 완료' }))
render(withCodes(<ProcessBoard org={org()} />))
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
await waitFor(() => expect(mocks.getProcessItem).toHaveBeenCalledWith(1, 1))
await waitFor(() => {
expect((screen.getByLabelText('최종의견') as HTMLTextAreaElement).value).toBe('검토 완료')
})
})
it('권리처리상태를 선택하지 않으면 저장 버튼이 비활성이고, 고르면 활성화된다', async () => {
mocks.getProcessPage.mockResolvedValue(page())
mocks.getProcessItem.mockResolvedValue(item())
render(withCodes(<ProcessBoard org={org()} />))
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
await waitFor(() => expect(mocks.getProcessItem).toHaveBeenCalled())
expect(screen.getByRole('button', { name: '수정' })).toBeDisabled()
fireEvent.change(screen.getByLabelText('*권리처리상태'), { target: { value: '처리완료' } })
expect(screen.getByRole('button', { name: '수정' })).not.toBeDisabled()
})
it('계약서 유무 체크박스 조합과 기타 입력이 쉼표로 연결되어 저장 페이로드에 실린다', async () => {
mocks.getProcessPage.mockResolvedValue(page())
mocks.getProcessItem.mockResolvedValue(item())
mocks.updateProcessing.mockResolvedValue(item({ processStatus: '처리완료' }))
render(withCodes(<ProcessBoard org={org()} />))
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
await waitFor(() => expect(mocks.getProcessItem).toHaveBeenCalled())
fireEvent.click(screen.getByRole('checkbox', { name: '양도계약서' }))
fireEvent.click(screen.getByRole('checkbox', { name: '공문' }))
fireEvent.click(screen.getByRole('checkbox', { name: '기타' }))
fireEvent.change(screen.getByLabelText('기타 계약서 내용'), { target: { value: '직접 촬영본 사용동의' } })
fireEvent.change(screen.getByLabelText('*권리처리상태'), { target: { value: '처리완료' } })
fireEvent.click(screen.getByRole('button', { name: '수정' }))
await waitFor(() =>
expect(mocks.updateProcessing).toHaveBeenCalledWith(1, 1, {
contractDocs: '양도계약서, 공문, 기타:직접 촬영본 사용동의',
judgedKoglType: null,
judgedAiType: null,
finalOpinion: null,
judgmentBasis: null,
processStatus: '처리완료',
}),
)
// 저장 후 목록으로 복귀
await waitFor(() => expect(screen.getByRole('button', { name: '엑셀 업로드' })).toBeTruthy())
})
it('기존에 저장된 계약서 유무 값을 체크박스 상태로 역파싱해 보여준다', async () => {
mocks.getProcessPage.mockResolvedValue(page())
mocks.getProcessItem.mockResolvedValue(
item({ contractDocs: '양도계약서, 기타:구두 동의' }),
)
render(withCodes(<ProcessBoard org={org()} />))
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
await waitFor(() => expect(mocks.getProcessItem).toHaveBeenCalled())
expect(screen.getByRole('checkbox', { name: '양도계약서' })).toBeChecked()
expect(screen.getByRole('checkbox', { name: '기타' })).toBeChecked()
expect((screen.getByLabelText('기타 계약서 내용') as HTMLInputElement).value).toBe('구두 동의')
})
it('목록 버튼을 누르면 저장 없이 목록으로 돌아간다', async () => {
mocks.getProcessPage.mockResolvedValue(page())
mocks.getProcessItem.mockResolvedValue(item())
render(withCodes(<ProcessBoard org={org()} />))
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
await waitFor(() => expect(mocks.getProcessItem).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: '목록' }))
expect(screen.getByRole('button', { name: '엑셀 업로드' })).toBeTruthy()
expect(mocks.updateProcessing).not.toHaveBeenCalled()
})
it('상세 화면에서 삭제를 누르면 확인 후 deleteProcessItem을 호출하고 목록으로 돌아간다', async () => {
mocks.getProcessPage.mockResolvedValue(page())
mocks.getProcessItem.mockResolvedValue(item())
mocks.deleteProcessItem.mockResolvedValue(undefined)
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
render(withCodes(<ProcessBoard org={org()} />))
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
await waitFor(() => expect(mocks.getProcessItem).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: '삭제' }))
expect(confirmSpy).toHaveBeenCalled()
await waitFor(() => expect(mocks.deleteProcessItem).toHaveBeenCalledWith(1, 1))
await waitFor(() => expect(screen.getByRole('button', { name: '엑셀 업로드' })).toBeTruthy())
confirmSpy.mockRestore()
})
})