import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import WorkMemos from './WorkMemos' import type { Contact, Org, WorkMemo } from '../api/client' const mocks = vi.hoisted(() => ({ getMemos: vi.fn(), createMemo: vi.fn(), updateMemo: vi.fn(), deleteMemo: vi.fn(), })) vi.mock('../api/client', async (importOriginal) => ({ ...(await importOriginal()), getMemos: mocks.getMemos, createMemo: mocks.createMemo, updateMemo: mocks.updateMemo, deleteMemo: mocks.deleteMemo, })) 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', 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, } } function memo(overrides: Partial = {}): WorkMemo { return { id: 1, orgId: 1, contactName: '송민지 과장', body: '채널 생성 일정 문의', createdAt: new Date('2026-07-01T10:30:00').getTime(), ...overrides, } } beforeEach(() => { mocks.getMemos.mockReset() mocks.createMemo.mockReset() mocks.updateMemo.mockReset() mocks.deleteMemo.mockReset() }) describe('WorkMemos', () => { it('등록된 메모를 담당자와 본문과 함께 보여준다', async () => { mocks.getMemos.mockResolvedValue([memo()]) render() await waitFor(() => expect(screen.getByRole('listitem')).toBeTruthy()) const item = within(screen.getByRole('listitem')) expect(item.getByText('송민지 과장')).toBeTruthy() expect(item.getByText('채널 생성 일정 문의')).toBeTruthy() }) it('메모가 없으면 안내 문구를 보여준다', async () => { mocks.getMemos.mockResolvedValue([]) render() await waitFor(() => { expect( screen.getByText('등록된 메모가 없습니다. 통화 후 첫 메모를 남겨보세요.'), ).toBeTruthy() }) }) it('작성자를 고르는 칸은 없고, 자동 기록 안내만 보인다', async () => { mocks.getMemos.mockResolvedValue([]) render() await waitFor(() => expect(mocks.getMemos).toHaveBeenCalled()) expect(screen.queryByLabelText('담당자')).toBeNull() expect(screen.queryByLabelText('담당자 이름')).toBeNull() expect(screen.getByText('작성자는 로그인한 사용자로 자동 기록됩니다.')).toBeTruthy() }) it('메모 내용이 비어 있으면 등록 버튼이 비활성이고, 입력하면 활성화된다', async () => { mocks.getMemos.mockResolvedValue([]) render() await waitFor(() => expect(mocks.getMemos).toHaveBeenCalled()) const submit = screen.getByRole('button', { name: '등록' }) expect(submit).toBeDisabled() fireEvent.change(screen.getByLabelText('메모 내용'), { target: { value: '통화 내용' } }) expect(submit).not.toBeDisabled() }) it('등록을 누르면 본문만 보내고(작성자는 서버가 기록) 본문을 비운다', async () => { mocks.getMemos.mockResolvedValue([]) mocks.createMemo.mockResolvedValue(memo({ id: 2, body: '통화 내용', contactName: 'admin' })) render() await waitFor(() => expect(mocks.getMemos).toHaveBeenCalled()) fireEvent.change(screen.getByLabelText('메모 내용'), { target: { value: '통화 내용' } }) fireEvent.click(screen.getByRole('button', { name: '등록' })) await waitFor(() => { expect(mocks.createMemo).toHaveBeenCalledWith(1, { body: '통화 내용' }) }) await waitFor(() => { expect((screen.getByLabelText('메모 내용') as HTMLTextAreaElement).value).toBe('') }) // 서버가 기록한 작성자가 목록에 그대로 보인다 expect(screen.getByText('admin')).toBeTruthy() }) it('수정을 누르면 본문을 그 자리에서 고치고 저장하면 updateMemo를 부른다', async () => { mocks.getMemos.mockResolvedValue([memo({ id: 7, body: '원래 내용' })]) mocks.updateMemo.mockResolvedValue(memo({ id: 7, body: '고친 내용' })) render() await waitFor(() => expect(screen.getByText('원래 내용')).toBeTruthy()) fireEvent.click(screen.getByRole('button', { name: '수정' })) fireEvent.change(screen.getByLabelText('메모 수정'), { target: { value: '고친 내용' } }) fireEvent.click(screen.getByRole('button', { name: '저장' })) await waitFor(() => expect(mocks.updateMemo).toHaveBeenCalledWith(1, 7, { body: '고친 내용' })) await waitFor(() => expect(screen.getByText('고친 내용')).toBeTruthy()) }) it('수정 중 취소를 누르면 원래 내용으로 돌아간다', async () => { mocks.getMemos.mockResolvedValue([memo({ id: 7, body: '원래 내용' })]) render() await waitFor(() => expect(screen.getByText('원래 내용')).toBeTruthy()) fireEvent.click(screen.getByRole('button', { name: '수정' })) fireEvent.change(screen.getByLabelText('메모 수정'), { target: { value: '버릴 내용' } }) fireEvent.click(screen.getByRole('button', { name: '취소' })) expect(screen.queryByLabelText('메모 수정')).toBeNull() expect(screen.getByText('원래 내용')).toBeTruthy() expect(mocks.updateMemo).not.toHaveBeenCalled() }) it('삭제를 누르면 확인을 물어보고, 확인하면 deleteMemo를 호출한다', async () => { mocks.getMemos.mockResolvedValue([memo()]) mocks.deleteMemo.mockResolvedValue(undefined) const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) render() await waitFor(() => expect(screen.getByText('채널 생성 일정 문의')).toBeTruthy()) fireEvent.click(screen.getByRole('button', { name: '삭제' })) expect(confirmSpy).toHaveBeenCalledWith('이 메모를 삭제할까요?') await waitFor(() => expect(mocks.deleteMemo).toHaveBeenCalledWith(1, 1)) confirmSpy.mockRestore() }) it('삭제 확인을 취소하면 deleteMemo를 호출하지 않는다', async () => { mocks.getMemos.mockResolvedValue([memo()]) const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) render() await waitFor(() => expect(screen.getByText('채널 생성 일정 문의')).toBeTruthy()) fireEvent.click(screen.getByRole('button', { name: '삭제' })) expect(confirmSpy).toHaveBeenCalled() expect(mocks.deleteMemo).not.toHaveBeenCalled() confirmSpy.mockRestore() }) })