import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import OrgDetail from './OrgDetail' import type { Org } from '../api/client' const mocks = vi.hoisted(() => ({ updateContact: vi.fn(), provisionChannels: vi.fn(), })) vi.mock('../api/client', async (importOriginal) => ({ ...(await importOriginal()), updateContact: mocks.updateContact, provisionChannels: mocks.provisionChannels, })) function org(overrides: Partial = {}): Org { return { id: 1, orgNo: '001', orgName: '국제방송교류재단', status: 'INFO_PENDING', deptName: null, managerName: null, managerTitle: null, managerPhone: null, managerEmail: null, mjDeptName: null, mjManagerName: null, mjManagerPhone: null, mjManagerEmail: null, lawyerName: null, lawyerPhone: null, lawyerEmail: null, lawyerAssignedDate: null, channelIdMj: null, channelIdLaw: null, ...overrides, } } /** 세 담당자 그룹은 부서명/연락처/이메일 같은 라벨을 공유하므로, 그룹 제목으로 찾은 * 컨테이너 안에서만 찾도록 스코프를 좁힌다 - 그렇지 않으면 getByLabelText가 * "여러 개 일치"로 실패한다. */ function group(title: string) { const heading = screen.getByText(title) return within(heading.closest('div') as HTMLElement) } beforeEach(() => { mocks.updateContact.mockReset() mocks.provisionChannels.mockReset() }) describe('OrgDetail', () => { it('정보 대기 상태면 채널 생성 버튼이 비활성이다', () => { render( {}} />) expect(screen.getByRole('button', { name: '채널 생성' })).toBeDisabled() }) it('생성 가능 상태면 채널 생성 버튼이 활성이다', () => { render( {}} />) expect(screen.getByRole('button', { name: '채널 생성' })).toBeEnabled() }) it('담당자 정보를 저장하면 API를 호출한다', async () => { mocks.updateContact.mockResolvedValue(org({ status: 'READY' })) const onChanged = vi.fn() render() const applicant = group('신청기관 담당자') fireEvent.change(applicant.getByLabelText('부서명'), { target: { value: '데이터정보화팀' } }) fireEvent.change(applicant.getByLabelText('담당자명'), { target: { value: '송민지' } }) fireEvent.change(applicant.getByLabelText('직급/직함'), { target: { value: '과장' } }) fireEvent.change(applicant.getByLabelText('연락처'), { target: { value: '02-3475-5434' } }) fireEvent.change(applicant.getByLabelText('이메일'), { target: { value: 'ming@arirang.com' } }) fireEvent.click(screen.getByRole('button', { name: '정보 저장' })) await waitFor(() => { expect(mocks.updateContact).toHaveBeenCalledWith(1, { deptName: '데이터정보화팀', managerName: '송민지', managerTitle: '과장', managerPhone: '02-3475-5434', managerEmail: 'ming@arirang.com', mjDeptName: '', mjManagerName: '', mjManagerPhone: '', mjManagerEmail: '', lawyerName: '', lawyerPhone: '', lawyerEmail: '', lawyerAssignedDate: '', }) expect(onChanged).toHaveBeenCalled() }) }) it('문정원 담당자와 담당 변호사 정보를 입력하면 한 번의 저장으로 함께 전송된다', async () => { mocks.updateContact.mockResolvedValue(org({ status: 'READY' })) const onChanged = vi.fn() render() const applicant = group('신청기관 담당자') fireEvent.change(applicant.getByLabelText('부서명'), { target: { value: '데이터정보화팀' } }) fireEvent.change(applicant.getByLabelText('담당자명'), { target: { value: '송민지' } }) fireEvent.change(applicant.getByLabelText('연락처'), { target: { value: '02-3475-5434' } }) fireEvent.change(applicant.getByLabelText('이메일'), { target: { value: 'ming@arirang.com' } }) const mj = group('문정원 담당자') fireEvent.change(mj.getByLabelText('부서명'), { target: { value: '문화체육관광부 저작권정책과' } }) fireEvent.change(mj.getByLabelText('담당자명'), { target: { value: '김문정' } }) fireEvent.change(mj.getByLabelText('연락처'), { target: { value: '02-1234-5678' } }) fireEvent.change(mj.getByLabelText('이메일'), { target: { value: 'mj@mcst.go.kr' } }) const lawyer = group('담당 변호사') fireEvent.change(lawyer.getByLabelText('성명'), { target: { value: '이변호' } }) fireEvent.change(lawyer.getByLabelText('연락처'), { target: { value: '02-9876-5432' } }) fireEvent.change(lawyer.getByLabelText('이메일'), { target: { value: 'lawyer@lawfirm.kr' } }) fireEvent.change(lawyer.getByLabelText('배정일'), { target: { value: '2026-07-21' } }) fireEvent.click(screen.getByRole('button', { name: '정보 저장' })) await waitFor(() => { expect(mocks.updateContact).toHaveBeenCalledWith(1, { deptName: '데이터정보화팀', managerName: '송민지', managerTitle: '', managerPhone: '02-3475-5434', managerEmail: 'ming@arirang.com', mjDeptName: '문화체육관광부 저작권정책과', mjManagerName: '김문정', mjManagerPhone: '02-1234-5678', mjManagerEmail: 'mj@mcst.go.kr', lawyerName: '이변호', lawyerPhone: '02-9876-5432', lawyerEmail: 'lawyer@lawfirm.kr', lawyerAssignedDate: '2026-07-21', }) expect(onChanged).toHaveBeenCalled() }) }) it('채널 생성은 확인 다이얼로그에 만들어질 채널 2개를 보여준다', () => { render( {}} />) fireEvent.click(screen.getByRole('button', { name: '채널 생성' })) expect(screen.getByText('001_국제방송교류재단 (문정원)')).toBeTruthy() expect(screen.getByText('001_국제방송교류재단 (법률검토)')).toBeTruthy() expect(mocks.provisionChannels).not.toHaveBeenCalled() }) it('확인을 눌러야 채널 생성 API가 호출된다', async () => { mocks.provisionChannels.mockResolvedValue({ mj: 'CREATED', law: 'CREATED', message: '' }) render( {}} />) fireEvent.click(screen.getByRole('button', { name: '채널 생성' })) fireEvent.click(screen.getByRole('button', { name: '생성' })) await waitFor(() => expect(mocks.provisionChannels).toHaveBeenCalledWith(1)) }) it('부분 실패하면 재시도 안내를 보여준다', async () => { mocks.provisionChannels.mockResolvedValue({ mj: 'CREATED', law: 'FAILED', message: '[법률검토] 서버 오류', }) render( {}} />) fireEvent.click(screen.getByRole('button', { name: '채널 생성' })) fireEvent.click(screen.getByRole('button', { name: '생성' })) await waitFor(() => { expect(screen.getByText(/법률검토 채널 생성에 실패/)).toBeTruthy() expect(screen.getByText(/다시 눌러도 안전/)).toBeTruthy() }) }) it('양쪽 다 실패하면 문정원 채널이 살아있다는 잘못된 안내를 보여주지 않는다', async () => { mocks.provisionChannels.mockResolvedValue({ mj: 'FAILED', law: 'FAILED', message: '[문정원] 서버 오류\n[법률검토] 서버 오류', }) render( {}} />) fireEvent.click(screen.getByRole('button', { name: '채널 생성' })) fireEvent.click(screen.getByRole('button', { name: '생성' })) await waitFor(() => { expect(screen.getByText(/문정원 채널.*법률검토 채널.*모두 생성에 실패/)).toBeTruthy() }) expect(screen.queryByText(/문정원 채널은 그대로 남아 있으니/)).toBeNull() }) it('문정원만 실패하면 법률검토 채널이 살아있다는 안내를 보여준다', async () => { mocks.provisionChannels.mockResolvedValue({ mj: 'FAILED', law: 'CREATED', message: '[문정원] 서버 오류', }) render( {}} />) fireEvent.click(screen.getByRole('button', { name: '채널 생성' })) fireEvent.click(screen.getByRole('button', { name: '생성' })) await waitFor(() => { expect(screen.getByText(/문정원 채널 생성에 실패/)).toBeTruthy() expect(screen.getByText(/법률검토 채널은 그대로 남아 있으니/)).toBeTruthy() }) }) it('채널 생성이 진행 중이면 생성 버튼과 채널 생성 버튼이 비활성화된다', async () => { let resolvePromise: (value: { mj: string; law: string; message: string }) => void = () => {} mocks.provisionChannels.mockReturnValue( new Promise((resolve) => { resolvePromise = resolve }), ) render( {}} />) fireEvent.click(screen.getByRole('button', { name: '채널 생성' })) fireEvent.click(screen.getByRole('button', { name: '생성' })) await waitFor(() => { expect(screen.getByRole('button', { name: '생성' })).toBeDisabled() }) expect(screen.getByRole('button', { name: '채널 생성' })).toBeDisabled() resolvePromise({ mj: 'CREATED', law: 'CREATED', message: '' }) await waitFor(() => { expect(screen.getByRole('button', { name: '채널 생성' })).toBeEnabled() }) }) it('필수 항목이 비어있으면 정보 저장 버튼이 비활성화된다', () => { render( {}} />) expect(screen.getByRole('button', { name: '정보 저장' })).toBeDisabled() }) it('직급/직함이 비어있어도 나머지 필수 항목만 채우면 정보 저장 버튼이 활성화된다', () => { render( {}} />) const applicant = group('신청기관 담당자') fireEvent.change(applicant.getByLabelText('부서명'), { target: { value: '데이터정보화팀' } }) fireEvent.change(applicant.getByLabelText('담당자명'), { target: { value: '송민지' } }) fireEvent.change(applicant.getByLabelText('연락처'), { target: { value: '02-3475-5434' } }) fireEvent.change(applicant.getByLabelText('이메일'), { target: { value: 'ming@arirang.com' } }) expect(screen.getByRole('button', { name: '정보 저장' })).toBeEnabled() }) it('문정원 담당자와 담당 변호사 항목을 채워도 신청기관 필수 항목이 비어있으면 저장 버튼은 비활성이다', () => { render( {}} />) const mj = group('문정원 담당자') fireEvent.change(mj.getByLabelText('담당자명'), { target: { value: '김문정' } }) const lawyer = group('담당 변호사') fireEvent.change(lawyer.getByLabelText('성명'), { target: { value: '이변호' } }) expect(screen.getByRole('button', { name: '정보 저장' })).toBeDisabled() }) it('reload로 org 객체가 새로 만들어져도 같은 기관이면 결과가 지워지지 않는다', async () => { mocks.provisionChannels.mockResolvedValue({ mj: 'CREATED', law: 'FAILED', message: '[법률검토] 서버 오류', }) const { rerender } = render( {}} />) fireEvent.click(screen.getByRole('button', { name: '채널 생성' })) fireEvent.click(screen.getByRole('button', { name: '생성' })) await waitFor(() => { expect(screen.getByText(/법률검토 채널 생성에 실패/)).toBeTruthy() }) // App.reload()가 만드는, id는 같지만 identity가 다른 새 객체를 흉내낸다. rerender( {}} />, ) expect(screen.getByText(/법률검토 채널 생성에 실패/)).toBeTruthy() }) it('다른 기관으로 전환하면 이전 결과와 입력값이 초기화된다', async () => { mocks.provisionChannels.mockResolvedValue({ mj: 'CREATED', law: 'FAILED', message: '[법률검토] 서버 오류', }) const { rerender } = render( {}} />) fireEvent.click(screen.getByRole('button', { name: '채널 생성' })) fireEvent.click(screen.getByRole('button', { name: '생성' })) await waitFor(() => { expect(screen.getByText(/법률검토 채널 생성에 실패/)).toBeTruthy() }) rerender( {}} />) expect(screen.queryByText(/법률검토 채널 생성에 실패/)).toBeNull() }) })