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()), ...mocks, })) function orgRow(overrides: Partial = {}): DashboardOrgRow { return { id: 1, orgNo: '001_00', orgName: '국제방송교류재단', status: 'ACTIVE', 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 { return { org: orgRow(), lastContactedOn: null, contactCount: 0, reportCount: 0, ...overrides, } } function log(overrides: Partial = {}): ContactLog { return { id: 1, orgId: 1, contactedOn: '2026-07-20', method: '전화', summary: '신청 확인 통화', author: 'admin', createdAt: Date.now(), ...overrides, } } function report(overrides: Partial = {}): OrgReport { return { id: 1, orgId: 1, fileName: '최종보고서.pdf', contentType: 'application/pdf', byteSize: 2048, uploadedBy: 'admin', uploadedAt: Date.now(), ...overrides, } } async function openPanel() { render(withCodes()) 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()) const table = within(await screen.findByTestId('project-table')) expect(table.getByText('국제방송교류재단')).toBeTruthy() // 단계 태그는 번호와 이름이 각각 다른 span이다(번호는 tabular-nums로 세로 정렬된다). expect(table.getByText('5.')).toBeTruthy() expect(table.getByText('법률검토(권리확인)')).toBeTruthy() expect(table.getByText('2026-07-20')).toBeTruthy() expect(table.getByText('(3)')).toBeTruthy() }) it('기관명을 클릭하면 기관관리로 넘긴다', async () => { const onOpenOrg = vi.fn() render(withCodes()) 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()) 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', ) }) })