import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'; import { OverlayProvider } from 'overlay-kit'; import { MemoryRouter } from 'react-router'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SampleTablePage } from './SampleTablePage'; const SEED_POSTS = [ { id: 1, userId: 1, title: '가 첫 글', body: '본문 1' }, { id: 2, userId: 1, title: '나 둘째 글', body: '본문 2' }, { id: 3, userId: 1, title: '다 셋째 글', body: '본문 3' }, { id: 4, userId: 2, title: '라 넷째 글', body: '본문 4' }, { id: 5, userId: 2, title: '마 다섯째 글', body: '본문 5' }, { id: 6, userId: 3, title: '바 여섯째 글', body: '본문 6' }, { id: 7, userId: 3, title: '사 일곱째 글', body: '본문 7' }, ]; // _generated 직접 import는 depcruise가 봉인한다 — 목 데이터는 vi.hoisted 참조로 주입. const mocks = vi.hoisted(() => ({ posts: [] as { id: number; userId: number; title: string; body: string }[], deleteFails: false, listFailures: 0, })); vi.mock('@/shared/api/_generated/api', () => ({ getPosts: vi.fn(async () => { if (mocks.listFailures > 0) { mocks.listFailures -= 1; throw new Error('mock: 목록 조회 실패'); } return mocks.posts; }), getPost: vi.fn(), createPost: vi.fn(), deletePost: vi.fn(async () => { if (mocks.deleteFails) { throw new Error('mock: 삭제 실패'); } }), })); beforeEach(() => { mocks.posts = SEED_POSTS; mocks.deleteFails = false; mocks.listFailures = 0; }); const renderPage = ( searchParams = '', onUrlUpdate?: (event: UrlUpdateEvent) => void ) => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); return render( ); }; const findTable = async () => within(await screen.findByRole('table')); describe('SampleTablePage', () => { it('첫 페이지에는 페이지 크기(5개)만큼만 보여준다', async () => { renderPage(); const table = await findTable(); expect(table.getByText('가 첫 글')).toBeInTheDocument(); expect(table.getByText('마 다섯째 글')).toBeInTheDocument(); expect(table.queryByText('바 여섯째 글')).not.toBeInTheDocument(); }); it('URL의 검색어(q)로 목록을 거른다', async () => { renderPage('?q=둘째'); const table = await findTable(); expect(table.getByText('나 둘째 글')).toBeInTheDocument(); expect(table.queryByText('가 첫 글')).not.toBeInTheDocument(); }); it('URL의 작성자(author)로 목록을 거른다', async () => { renderPage('?author=3'); const table = await findTable(); expect(table.getByText('바 여섯째 글')).toBeInTheDocument(); expect(table.getByText('사 일곱째 글')).toBeInTheDocument(); expect(table.queryByText('가 첫 글')).not.toBeInTheDocument(); }); it('URL의 페이지(page)로 다음 페이지를 보여준다', async () => { renderPage('?page=2'); const table = await findTable(); expect(table.getByText('바 여섯째 글')).toBeInTheDocument(); expect(table.queryByText('가 첫 글')).not.toBeInTheDocument(); }); it('검색어를 바꾸면 페이지가 1로 되돌아간다', async () => { const updates: string[] = []; renderPage('?page=2', (event) => { updates.push(event.queryString); }); await screen.findByRole('table'); fireEvent.change(screen.getByLabelText('글 검색'), { target: { value: '둘째' }, }); await waitFor(() => { const last = updates.at(-1) ?? ''; expect(last).toContain('q='); expect(last).not.toContain('page='); }); }); it('목록 패널과 새 글 버튼을 함께 보여준다', async () => { renderPage(); await screen.findByRole('table'); expect(screen.getByText('최근 글')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '새 글' })).toBeInTheDocument(); expect( screen.getAllByRole('button', { name: '삭제' }).length ).toBeGreaterThan(0); }); it('삭제를 확정하면 표와 목록이 다시 조회된다', async () => { renderPage(); await screen.findByRole('table'); mocks.posts = SEED_POSTS.slice(1); fireEvent.click(screen.getAllByRole('button', { name: '삭제' })[0]); const dialog = await screen.findByRole('dialog'); fireEvent.click(within(dialog).getByRole('button', { name: '삭제' })); await waitFor(() => { expect(screen.queryAllByText('가 첫 글')).toHaveLength(0); }); expect(screen.getAllByText('나 둘째 글').length).toBeGreaterThan(0); }); it('삭제가 실패하면 목록이 그대로 남고 버튼이 다시 눌린다', async () => { mocks.deleteFails = true; renderPage(); await screen.findByRole('table'); fireEvent.click(screen.getAllByRole('button', { name: '삭제' })[0]); const dialog = await screen.findByRole('dialog'); fireEvent.click(within(dialog).getByRole('button', { name: '삭제' })); await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull(); }); expect(screen.getAllByText('가 첫 글').length).toBeGreaterThan(0); await waitFor(() => { expect(screen.getAllByRole('button', { name: '삭제' })[0]).toBeEnabled(); }); }); it('목록을 못 불러왔을 때 다시 시도가 실제로 다시 불러온다', async () => { mocks.listFailures = 1; renderPage(); expect( await screen.findByText('대시보드를 불러오지 못했어요.') ).toBeInTheDocument(); fireEvent.click(screen.getAllByRole('button', { name: '다시 시도' })[0]); const table = await findTable(); expect(table.getByText('가 첫 글')).toBeInTheDocument(); }); });