import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import OrgOverview from './OrgOverview' import type { Contact, Org } from '../api/client' const mocks = vi.hoisted(() => ({ getPosts: vi.fn(), getFiles: vi.fn(), getMemos: vi.fn(), getContacts: vi.fn(), updateAssignments: vi.fn(), getTimeline: vi.fn(), updateStage: vi.fn(), getStageHistory: vi.fn(), })) vi.mock('../api/client', async (importOriginal) => ({ ...(await importOriginal()), getPosts: mocks.getPosts, getFiles: mocks.getFiles, getMemos: mocks.getMemos, getContacts: mocks.getContacts, updateAssignments: mocks.updateAssignments, getTimeline: mocks.getTimeline, updateStage: mocks.updateStage, getStageHistory: mocks.getStageHistory, })) function contact(overrides: Partial = {}): Contact { return { id: 1, category: 'APPLICANT', name: '이름없음', affiliation: null, deptName: null, title: null, phone: null, email: null, ...overrides, } } function org(overrides: Partial = {}): Org { return { id: 1, orgNo: '001_00', orgName: '국제방송교류재단', status: 'ACTIVE', applicant: contact({ id: 1, category: 'APPLICANT', name: '송민지', affiliation: '국제방송교류재단', deptName: '데이터정보화팀', title: '과장', phone: '02-3475-5434', email: 'ming@arirang.com', }), mj: null, itn: null, lawyer: null, lawyerAssignedDate: null, channelIdMj: 'chan-mj', channelIdLaw: 'chan-law', stage: null, ...overrides, } } beforeEach(() => { mocks.getPosts.mockReset().mockResolvedValue([]) mocks.getFiles.mockReset().mockResolvedValue([]) mocks.getMemos.mockReset().mockResolvedValue([]) mocks.getContacts.mockReset().mockResolvedValue([]) mocks.updateAssignments.mockReset().mockResolvedValue(undefined) mocks.getStageHistory.mockReset().mockResolvedValue([]) mocks.getTimeline.mockReset().mockResolvedValue([]) mocks.updateStage.mockReset().mockResolvedValue(undefined) }) describe('OrgOverview', () => { it('연번_기관명 헤더와 신청기관 담당자 정보를 보여준다', async () => { render() expect(screen.getByText('001_00_국제방송교류재단')).toBeTruthy() expect(screen.getByText('데이터정보화팀')).toBeTruthy() expect(screen.getByText('송민지 과장')).toBeTruthy() expect(screen.getByText('02-3475-5434')).toBeTruthy() expect(screen.getByText('ming@arirang.com')).toBeTruthy() await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) }) it('문정원 담당자와 아이티앤 담당자와 담당 변호사 정보가 비어 있으면 카드에 -로 표시된다', async () => { render() const mjCard = screen.getByText('문정원 담당자').closest('section') as HTMLElement const itnCard = screen.getByText('아이티앤 담당자').closest('section') as HTMLElement const lawyerCard = screen.getByText('담당 변호사').closest('section') as HTMLElement expect(within(mjCard).getAllByText('-')).toHaveLength(4) expect(within(itnCard).getAllByText('-')).toHaveLength(4) expect(within(lawyerCard).getAllByText('-')).toHaveLength(4) await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) }) it('담당자 4종이 값을 가지고 있으면 각 카드에 값이 표시된다', async () => { render( , ) const applicantCard = screen.getByText('신청기관 담당자').closest('section') as HTMLElement const mjCard = screen.getByText('문정원 담당자').closest('section') as HTMLElement const itnCard = screen.getByText('아이티앤 담당자').closest('section') as HTMLElement const lawyerCard = screen.getByText('담당 변호사').closest('section') as HTMLElement expect(within(applicantCard).getByText('데이터정보화팀')).toBeTruthy() expect(within(applicantCard).getByText('송민지 과장')).toBeTruthy() expect(within(mjCard).getByText('문화체육관광부 저작권정책과')).toBeTruthy() expect(within(mjCard).getByText('김문정')).toBeTruthy() expect(within(mjCard).getByText('02-1234-5678')).toBeTruthy() expect(within(mjCard).getByText('mj@mcst.go.kr')).toBeTruthy() expect(within(itnCard).getByText('개발팀')).toBeTruthy() expect(within(itnCard).getByText('박아이티')).toBeTruthy() expect(within(itnCard).getByText('02-1111-2222')).toBeTruthy() expect(within(itnCard).getByText('itn@iten.co.kr')).toBeTruthy() expect(within(lawyerCard).getByText('이변호')).toBeTruthy() expect(within(lawyerCard).getByText('02-9876-5432')).toBeTruthy() expect(within(lawyerCard).getByText('lawyer@lawfirm.kr')).toBeTruthy() expect(within(lawyerCard).getByText('2026-07-21')).toBeTruthy() await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) }) it('배정된 카드에는 변경, 빈 카드에는 생성 버튼이 보인다', async () => { render() expect(screen.getByRole('button', { name: '신청기관 담당자 변경' })).toBeTruthy() expect(screen.getByRole('button', { name: '문정원 담당자 생성' })).toBeTruthy() expect(screen.getByRole('button', { name: '아이티앤 담당자 생성' })).toBeTruthy() expect(screen.getByRole('button', { name: '담당 변호사 생성' })).toBeTruthy() await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) }) it('생성 버튼을 누르면 선택 모달이 열리고 담당자를 고르면 배정 API를 부른다', async () => { mocks.getContacts.mockResolvedValue([ contact({ id: 7, category: 'MJ', name: '이연경', deptName: '공공저작물팀' }), ]) const onChanged = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: '문정원 담당자 생성' })) await waitFor(() => expect(screen.getByText('문정원 담당자 선택')).toBeTruthy()) fireEvent.click(await screen.findByRole('button', { name: '선택' })) await waitFor(() => expect(mocks.updateAssignments).toHaveBeenCalledWith(1, { applicantContactId: 1, mjContactId: 7, itnContactId: null, lawyerContactId: null, lawyerAssignedDate: null, }), ) expect(onChanged).toHaveBeenCalled() }) it('아이티앤 담당자 생성 버튼을 누르면 선택 모달이 열리고 담당자를 고르면 itnContactId로 배정 API를 부른다', async () => { mocks.getContacts.mockResolvedValue([ contact({ id: 8, category: 'ITN', name: '박아이티', deptName: '개발팀' }), ]) const onChanged = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: '아이티앤 담당자 생성' })) await waitFor(() => expect(screen.getByText('아이티앤 담당자 선택')).toBeTruthy()) fireEvent.click(await screen.findByRole('button', { name: '선택' })) await waitFor(() => expect(mocks.updateAssignments).toHaveBeenCalledWith(1, { applicantContactId: 1, mjContactId: null, itnContactId: 8, lawyerContactId: null, lawyerAssignedDate: null, }), ) expect(onChanged).toHaveBeenCalled() }) it('상단 동작 버튼에서 담당 변호사 변경은 사라졌다', async () => { render() expect(screen.queryByRole('button', { name: '담당 변호사 변경' })).toBeNull() expect(screen.getByRole('button', { name: '진행단계 변경' })).not.toBeDisabled() await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) }) it('완료된 단계와 진행중 단계 아래에 도달 날짜가 찍힌다', async () => { mocks.getStageHistory.mockResolvedValue([ { stage: 3, changedAt: new Date('2026-07-24T10:00:00').getTime() }, { stage: 2, changedAt: new Date('2026-07-20T09:00:00').getTime() }, { stage: 1, changedAt: new Date('2026-07-15T09:00:00').getTime() }, ]) render() await waitFor(() => { expect(screen.getByText('2026-07-24 10:00')).toBeTruthy() expect(screen.getByText('2026-07-20 09:00')).toBeTruthy() expect(screen.getByText('2026-07-15 09:00')).toBeTruthy() }) }) it('업무단계 12개가 순서대로 보인다', async () => { render() expect(screen.getByText('신청')).toBeTruthy() expect(screen.getByText('변호사 배당')).toBeTruthy() expect(screen.getByText('보고서 작성')).toBeTruthy() await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) }) it('기본 탭인 채팅은 자리표시 대신 채널 선택 화면을 보여준다', async () => { render() expect(screen.getByRole('button', { name: '문정원' })).toBeTruthy() await waitFor(() => expect(mocks.getPosts).toHaveBeenCalledWith(1, 'mj')) }) it('탭을 누르면 해당 탭의 자리표시 내용으로 바뀐다', async () => { render() await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) fireEvent.click(screen.getByRole('button', { name: '권리확인' })) expect(screen.getByText('권리확인 상세 화면은 추후 확정')).toBeTruthy() expect(screen.queryByRole('button', { name: '문정원' })).toBeNull() }) it('타임라인 탭을 누르면 Timeline이 마운트되어 조회를 부른다', async () => { render() await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) fireEvent.click(screen.getByRole('button', { name: '타임라인' })) await waitFor(() => expect(mocks.getTimeline).toHaveBeenCalledWith(1)) expect(screen.queryByText('타임라인 상세 화면은 추후 확정')).toBeNull() }) it('자료 탭을 누르면 자료 목록 화면으로 바뀐다', async () => { render() fireEvent.click(screen.getByRole('button', { name: '자료' })) await waitFor(() => expect(mocks.getFiles).toHaveBeenCalledWith(1, 'mj')) expect(screen.getByRole('button', { name: '새로고침' })).toBeTruthy() }) it('채널이 있으면 Mattermost 열기가 채널 링크로 활성화된다', async () => { render() const link = screen.getByRole('link', { name: 'Mattermost 열기' }) expect(link).toHaveAttribute( 'href', 'https://hub.iten.co.kr/itn-hub/channels/org-001-00-mj', ) await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) }) it('채널이 없으면 Mattermost 열기가 비활성이다', () => { render() expect(screen.queryByRole('link', { name: 'Mattermost 열기' })).toBeNull() expect(screen.getByRole('button', { name: 'Mattermost 열기' })).toBeDisabled() }) it('치안정책연구소도 합성코드 슬러그로 채널 링크를 만든다', async () => { render( , ) expect(screen.getByRole('link', { name: 'Mattermost 열기' })).toHaveAttribute( 'href', 'https://hub.iten.co.kr/itn-hub/channels/org-008-01-mj', ) await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) }) it('미구현 동작 버튼들은 비활성이다', async () => { render() expect(screen.getByRole('button', { name: '자료 업로드' })).toBeDisabled() expect(screen.getByRole('button', { name: '최초 게시글 보기' })).toBeDisabled() expect(screen.getByRole('button', { name: '보고서 관리' })).toBeDisabled() await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) }) describe('업무단계 현황 스트립', () => { it('채널이 없어 단계가 시작되지 않았으면 전부 회색이고 안내 문구가 보인다', async () => { render() expect(screen.getByText('채널 생성 시 신청 단계가 시작됩니다')).toBeTruthy() expect(screen.queryByText('진행중')).toBeNull() }) it('현재 단계 이전은 완료(체크) 표시, 현재 단계는 진행중, 이후는 미래 상태로 보인다', async () => { render() const doneButton = screen.getByText('목록접수').closest('button') as HTMLElement const currentButton = screen.getByText('예비검토').closest('button') as HTMLElement const futureButton = screen.getByText('변호사 배당').closest('button') as HTMLElement expect(within(doneButton).getByText('✓')).toBeTruthy() expect(within(currentButton).getByText('진행중')).toBeTruthy() expect(within(futureButton).getByText('-')).toBeTruthy() expect(futureButton).toBeDisabled() expect(doneButton).toBeDisabled() expect(currentButton).not.toBeDisabled() await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled()) }) it('현재 단계를 클릭하면 확인 후 다음 단계로 진행단계 변경 API를 부른다', async () => { const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) const onChanged = vi.fn() render() const currentButton = screen.getByText('변호사 배당').closest('button') as HTMLElement fireEvent.click(currentButton) expect(confirmSpy).toHaveBeenCalledWith( '다음 단계 "5. 법률검토(권리확인)"(으)로 진행할까요?', ) await waitFor(() => expect(mocks.updateStage).toHaveBeenCalledWith(1, 5)) expect(onChanged).toHaveBeenCalled() confirmSpy.mockRestore() }) it('확인을 취소하면 진행단계 변경 API를 부르지 않는다', async () => { const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) render() fireEvent.click(screen.getByText('변호사 배당').closest('button') as HTMLElement) expect(confirmSpy).toHaveBeenCalled() expect(mocks.updateStage).not.toHaveBeenCalled() confirmSpy.mockRestore() }) it('현재 단계가 아닌 다른 단계를 클릭해도 아무 일도 일어나지 않는다', async () => { const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) render() fireEvent.click(screen.getByText('예비검토').closest('button') as HTMLElement) fireEvent.click(screen.getByText('RE:확인').closest('button') as HTMLElement) expect(confirmSpy).not.toHaveBeenCalled() expect(mocks.updateStage).not.toHaveBeenCalled() confirmSpy.mockRestore() }) it('마지막 단계(12)가 현재 단계이면 클릭해도 아무 일도 일어나지 않는다', async () => { const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) render() const lastButton = screen.getByText('보고서 작성').closest('button') as HTMLElement expect(lastButton).toBeDisabled() fireEvent.click(lastButton) expect(confirmSpy).not.toHaveBeenCalled() expect(mocks.updateStage).not.toHaveBeenCalled() confirmSpy.mockRestore() }) }) describe('진행단계 변경 모달', () => { it('버튼을 누르면 12개 단계 목록이 열리고 하나를 고르면 확인 후 해당 단계로 바꾼다', async () => { const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) const onChanged = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: '진행단계 변경' })) const modalHeading = screen.getByRole('heading', { name: '진행단계 변경' }) const modal = modalHeading.closest('div')!.parentElement as HTMLElement expect(within(modal).getByText('보고서 작성')).toBeTruthy() fireEvent.click(within(modal).getByText('법률검토(권리확인)')) expect(confirmSpy).toHaveBeenCalledWith( '"5. 법률검토(권리확인)"(으)로 진행단계를 변경할까요?', ) await waitFor(() => expect(mocks.updateStage).toHaveBeenCalledWith(1, 5)) expect(onChanged).toHaveBeenCalled() confirmSpy.mockRestore() }) it('닫기를 누르면 모달이 사라진다', async () => { render() fireEvent.click(screen.getByRole('button', { name: '진행단계 변경' })) expect(screen.getByRole('heading', { name: '진행단계 변경' })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: '닫기' })) expect(screen.queryByRole('heading', { name: '진행단계 변경' })).toBeNull() }) }) })