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()), getReviewPage: mocks.getReviewPage, getReviewItem: mocks.getReviewItem, importReview: mocks.importReview, updateReviewJudgment: mocks.updateReviewJudgment, deleteReviewItem: mocks.deleteReviewItem, })) function org(overrides: Partial = {}): 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 { 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 { 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() 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() await waitFor(() => { expect( screen.getByText('업로드된 권리확인 자료가 없습니다. 엑셀 업로드로 시작하세요.'), ).toBeTruthy() }) }) it('검색어를 입력하고 검색을 누르면 keyword로 다시 조회한다', async () => { mocks.getReviewPage.mockResolvedValue(page()) render() 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() 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() 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() 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() 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() 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() 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() 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() }) })