import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import ProcessBoard from './ProcessBoard' 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()), 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 { 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 { 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 { 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() 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() await waitFor(() => { expect( screen.getByText('업로드된 권리처리 자료가 없습니다. 엑셀 업로드로 시작하세요.'), ).toBeTruthy() }) }) it('상태 칩을 누르면 status 파라미터로 다시 조회한다', async () => { mocks.getProcessPage.mockResolvedValue(page()) render() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() }) })