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 ReviewBoard from './ReviewBoard'
import type { Org, ReviewItem, ReviewPage } from '../api/client'
const mocks = vi.hoisted(() => ({
getReviewPage: vi.fn(),
getReviewItem: vi.fn(),
importReview: vi.fn(),
updateReviewJudgment: vi.fn(),
deleteReviewItem: vi.fn(),
}))
vi.mock('../api/client', async (importOriginal) => ({
...(await importOriginal<typeof import('../api/client')>()),
getReviewPage: mocks.getReviewPage,
getReviewItem: mocks.getReviewItem,
importReview: mocks.importReview,
updateReviewJudgment: mocks.updateReviewJudgment,
deleteReviewItem: mocks.deleteReviewItem,
}))
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<ReviewItem> = {}): ReviewItem {
return {
id: 1,
orgId: 1,
seq: 1,
siteName: '한국출판문화산업진흥원',
category: '공지',
boardPath: '게시판>공지',
boardName: '공지사항',
postTitle: '2026년 사업 공고',
url: 'https://example.com/1',
postRegistered: '2026-01-01',
producedDate: '20260101',
publishedDate: '20260102',
hasAttachment: 'N',
koglAttached: 'Y',
koglType: '1유형',
aiType: null,
surveyorNote: null,
openable: null,
reviewMajor: null,
reviewMinor: null,
reviewResult: '신유형 개방',
judgedKoglType: null,
judgedAiType: null,
opinion: null,
lawyerNote: null,
needsProcessing: null,
webEditedAt: null,
createdAt: 0,
updatedAt: 0,
...overrides,
}
}
function page(overrides: Partial<ReviewPage> = {}): ReviewPage {
return {
items: [item()],
total: 1,
done: 0,
page: 0,
size: 30,
...overrides,
}
}
beforeEach(() => {
mocks.getReviewPage.mockReset()
mocks.getReviewItem.mockReset()
mocks.importReview.mockReset()
mocks.updateReviewJudgment.mockReset()
mocks.deleteReviewItem.mockReset()
})
describe('ReviewBoard 목록', () => {
it('목록을 표로 보여주고 처리결과 배지가 나온다', async () => {
mocks.getReviewPage.mockResolvedValue(page())
render(<ReviewBoard org={org()} />)
await waitFor(() => {
expect(screen.getByText('2026년 사업 공고')).toBeTruthy()
expect(screen.getByText('신유형 개방')).toBeTruthy()
})
expect(screen.getByText('총 1건 · 처리 0건')).toBeTruthy()
})
it('자료가 없으면 안내 문구를 보여준다', async () => {
mocks.getReviewPage.mockResolvedValue(page({ items: [], total: 0 }))
render(<ReviewBoard org={org()} />)
await waitFor(() => {
expect(
screen.getByText('업로드된 권리확인 자료가 없습니다. 엑셀 업로드로 시작하세요.'),
).toBeTruthy()
})
})
it('검색어를 입력하고 검색을 누르면 keyword로 다시 조회한다', async () => {
mocks.getReviewPage.mockResolvedValue(page())
render(<ReviewBoard org={org()} />)
await waitFor(() =>
expect(mocks.getReviewPage).toHaveBeenCalledWith(1, { keyword: undefined, page: 0, size: 30 }),
)
fireEvent.change(screen.getByLabelText('검색어'), { target: { value: '출판' } })
fireEvent.click(screen.getByRole('button', { name: '검색' }))
await waitFor(() =>
expect(mocks.getReviewPage).toHaveBeenCalledWith(1, { keyword: '출판', page: 0, size: 30 }),
)
})
it('엑셀 업로드를 하면 importReview를 호출하고 결과를 보여준다', async () => {
mocks.getReviewPage.mockResolvedValue(page())
mocks.importReview.mockResolvedValue({ created: 3, updated: 2, total: 5 })
const { container } = render(<ReviewBoard org={org()} />)
await waitFor(() => expect(mocks.getReviewPage).toHaveBeenCalledTimes(1))
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const file = new File(['dummy'], 'review.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
fireEvent.change(fileInput, { target: { files: [file] } })
await waitFor(() => expect(mocks.importReview).toHaveBeenCalledWith(1, file))
await waitFor(() => {
expect(screen.getByText('신규 3 · 갱신 2 · 전체 5')).toBeTruthy()
})
})
it('삭제를 누르면 확인 후 deleteReviewItem을 호출한다', async () => {
mocks.getReviewPage.mockResolvedValue(page())
mocks.deleteReviewItem.mockResolvedValue(undefined)
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
render(<ReviewBoard org={org()} />)
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: '삭제' }))
expect(confirmSpy).toHaveBeenCalled()
await waitFor(() => expect(mocks.deleteReviewItem).toHaveBeenCalledWith(1, 1))
confirmSpy.mockRestore()
})
it('다음 페이지 버튼을 누르면 page+1로 다시 조회한다', async () => {
mocks.getReviewPage.mockResolvedValue(page({ total: 31 }))
render(<ReviewBoard org={org()} />)
await waitFor(() =>
expect(mocks.getReviewPage).toHaveBeenCalledWith(1, { keyword: undefined, page: 0, size: 30 }),
)
fireEvent.click(screen.getByRole('button', { name: '›' }))
await waitFor(() =>
expect(mocks.getReviewPage).toHaveBeenCalledWith(1, { keyword: undefined, page: 1, size: 30 }),
)
})
})
describe('ReviewBoard 상세', () => {
it('수정을 누르면 상세 화면이 열리고 조회한 값이 채워진다', async () => {
mocks.getReviewPage.mockResolvedValue(page())
mocks.getReviewItem.mockResolvedValue(
item({ reviewMajor: '만료 저작물', opinion: '검토 완료' }),
)
render(<ReviewBoard org={org()} />)
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: '수정' }))
await waitFor(() => expect(mocks.getReviewItem).toHaveBeenCalledWith(1, 1))
await waitFor(() => {
expect((screen.getByLabelText('의견') as HTMLTextAreaElement).value).toBe('검토 완료')
})
expect(screen.getByRole('radio', { name: '만료 저작물' })).toBeChecked()
})
it('대분류를 바꾸면 처리결과가 자동으로 채워지지만, 수동으로 고르면 이후 대분류 변경에도 유지된다', async () => {
mocks.getReviewPage.mockResolvedValue(page())
mocks.getReviewItem.mockResolvedValue(item({ reviewMajor: null, reviewResult: null }))
render(<ReviewBoard org={org()} />)
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: '수정' }))
await waitFor(() => expect(mocks.getReviewItem).toHaveBeenCalled())
fireEvent.click(screen.getByRole('radio', { name: '만료 저작물' }))
expect(screen.getByRole('radio', { name: '신유형 개방' })).toBeChecked()
// 수동으로 다른 처리결과를 고른다
fireEvent.click(screen.getByRole('radio', { name: '권리처리 추진' }))
expect(screen.getByRole('radio', { name: '권리처리 추진' })).toBeChecked()
// 대분류를 다시 바꿔도(원래대로면 "신유형 개방"을 추천) 수동 선택이 유지된다
fireEvent.click(screen.getByRole('radio', { name: '업무상 저작물' }))
expect(screen.getByRole('radio', { name: '권리처리 추진' })).toBeChecked()
})
it('수정을 저장하면 폼 값으로 updateReviewJudgment를 호출하고 목록으로 돌아간다', async () => {
mocks.getReviewPage.mockResolvedValue(page())
mocks.getReviewItem.mockResolvedValue(item({ reviewMajor: null, reviewResult: null }))
mocks.updateReviewJudgment.mockResolvedValue(item({ reviewResult: '신유형 개방' }))
render(<ReviewBoard org={org()} />)
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: '수정' }))
await waitFor(() => expect(mocks.getReviewItem).toHaveBeenCalled())
fireEvent.click(screen.getByRole('radio', { name: '만료 저작물' }))
fireEvent.change(screen.getByLabelText('의견'), { target: { value: '이상 없음' } })
fireEvent.click(screen.getByRole('button', { name: '수정' }))
await waitFor(() =>
expect(mocks.updateReviewJudgment).toHaveBeenCalledWith(1, 1, {
openable: 'Y',
reviewMajor: '만료 저작물',
reviewMinor: null,
reviewResult: '신유형 개방',
judgedKoglType: null,
judgedAiType: null,
opinion: '이상 없음',
lawyerNote: null,
needsProcessing: null,
}),
)
// 저장 후 목록으로 복귀 - 업로드 버튼 등 목록 화면 요소가 다시 보인다
await waitFor(() => expect(screen.getByRole('button', { name: '엑셀 업로드' })).toBeTruthy())
})
it('목록 버튼을 누르면 저장 없이 목록으로 돌아간다', async () => {
mocks.getReviewPage.mockResolvedValue(page())
mocks.getReviewItem.mockResolvedValue(item())
render(<ReviewBoard org={org()} />)
await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: '수정' }))
await waitFor(() => expect(mocks.getReviewItem).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: '목록' }))
expect(screen.getByRole('button', { name: '엑셀 업로드' })).toBeTruthy()
expect(mocks.updateReviewJudgment).not.toHaveBeenCalled()
})
})