import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { HelmetProvider } from 'react-helmet-async' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import { loadPostImportSession, savePostImportSession } from '@/lib/postImportSession' import PostImportReviewPage from '@/pages/posts/PostImportReviewPage' import { buildUser } from '@/test/factories' import { buildPostImportRow } from '@/test/postImportFactories' import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue' import type { PostImportRow } from '@/lib/postImportSession' const api = vi.hoisted (() => ({ apiGet: vi.fn (), apiPost: vi.fn (), })) const toastApi = vi.hoisted (() => ({ toast: vi.fn (), })) const dialogue = vi.hoisted (() => ({ form: vi.fn (() => Promise.resolve ()), })) vi.mock ('@/lib/api', () => api) vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/lib/dialogues/useDialogue', () => ({ default: () => dialogue, })) const renderReviewPage = (route = '/posts/new/review') => render ( SOURCE ROUTE}/> }/> POSTS ROUTE}/> ) describe ('PostImportReviewPage', () => { beforeEach (() => { sessionStorage.clear () vi.clearAllMocks () globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview') globalThis.URL.revokeObjectURL = vi.fn () api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' })) }) it ('redirects to /posts/new when no current work exists', async () => { renderReviewPage () await waitFor (() => { expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument () }) }) it ('restores the current work on review reload', async () => { savePostImportSession ({ source: 'https://example.com/post', repairMode: 'all', rows: [buildPostImportRow ({ sourceRow: 1, attributes: { title: 'restored row' } })] }) renderReviewPage () expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument () expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument () expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument () expect (await screen.findByText ('restored row')).toBeInTheDocument () }) it ('does not expose メタデータ to the user', async () => { savePostImportSession ({ source: 'https://example.com/post', repairMode: 'all', rows: [buildPostImportRow ({ sourceRow: 1, fieldWarnings: { title: ['自動取得に失敗しました.'] } })] }) renderReviewPage () expect (screen.queryByText (/メタデータ/)).not.toBeInTheDocument () }) it ('returns to /posts/new while keeping the current work', async () => { savePostImportSession ({ source: 'https://example.com/post', repairMode: 'all', rows: [buildPostImportRow ({ sourceRow: 1, attributes: { title: 'restored row' } })] }) renderReviewPage () fireEvent.click (await screen.findByRole ('button', { name: 'URL リスト入力へ戻る' })) await waitFor (() => { expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument () }) expect (loadPostImportSession ()?.rows[0]?.attributes.title).toBe ('restored row') }) it ('disables row editing and navigation while batch import is running', async () => { let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null savePostImportSession ({ source: 'https://example.com/post', repairMode: 'all', rows: [buildPostImportRow ({ sourceRow: 1 })] }) api.apiPost.mockImplementationOnce (() => new Promise<{ rows: PostImportRow[] }> (resolve => { resolveValidation = resolve })) api.apiPost.mockResolvedValueOnce ({ created: 0, skipped: 0, failed: 0, rows: [] }) renderReviewPage () fireEvent.click (screen.getByRole ('button', { name: '取込実行' })) await waitFor (() => { expect (screen.getByRole ('button', { name: '編輯' })).toBeDisabled () expect (screen.getByRole ('button', { name: 'URL リスト入力へ戻る' })).toBeDisabled () expect (screen.getByRole ('button', { name: '取込実行' })).toBeDisabled () }) resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] }) }) it ('does not call import when validation omits a requested source row', async () => { savePostImportSession ({ source: 'https://example.com/post-1\nhttps://example.com/post-2', repairMode: 'all', rows: [ buildPostImportRow ({ sourceRow: 1 }), buildPostImportRow ({ sourceRow: 2 })] }) api.apiPost.mockResolvedValueOnce ({ rows: [buildPostImportRow ({ sourceRow: 1 })] }) renderReviewPage () fireEvent.click (screen.getByRole ('button', { name: '取込実行' })) await waitFor (() => { expect (toastApi.toast).toHaveBeenCalledWith ( expect.objectContaining ({ title: '再検証結果が不完全でした' })) }) expect (api.apiPost).toHaveBeenCalledTimes (1) }) it ('navigates to /posts when all rows finish as created or skipped', async () => { savePostImportSession ({ source: 'https://example.com/post', repairMode: 'all', rows: [buildPostImportRow ({ sourceRow: 1 })] }) api.apiPost .mockResolvedValueOnce ({ rows: [buildPostImportRow ({ sourceRow: 1 })] }) .mockResolvedValueOnce ({ created: 1, skipped: 0, failed: 0, rows: [{ sourceRow: 1, status: 'created', post: { id: 1 } }] }) renderReviewPage () fireEvent.click (screen.getByRole ('button', { name: '取込実行' })) await waitFor (() => { expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument () }) expect (loadPostImportSession ()).toBeNull () }) it ('stays on review when a recoverable failed row remains', async () => { savePostImportSession ({ source: 'https://example.com/post', repairMode: 'all', rows: [buildPostImportRow ({ sourceRow: 1 })] }) api.apiPost .mockResolvedValueOnce ({ rows: [buildPostImportRow ({ sourceRow: 1 })] }) .mockResolvedValueOnce ({ created: 0, skipped: 0, failed: 1, rows: [{ sourceRow: 1, status: 'failed', recoverable: true, errors: { title: ['invalid'] } }] }) renderReviewPage () fireEvent.click (screen.getByRole ('button', { name: '取込実行' })) expect (await screen.findByText ('invalid')).toBeInTheDocument () expect (screen.queryByText ('POSTS ROUTE')).not.toBeInTheDocument () }) it ('keeps edited recoverable rows in session before the next batch submit', async () => { savePostImportSession ({ source: 'https://example.com/post', repairMode: 'failed', rows: [buildPostImportRow ({ sourceRow: 1, attributes: { title: 'old title', duration: '2' }, importStatus: 'failed', recoverable: true, importErrors: { base: ['failed'] } })] }) api.apiPost .mockResolvedValueOnce ({ rows: [buildPostImportRow ({ sourceRow: 1, attributes: { title: 'edited title', duration: '2' }, importStatus: 'pending', recoverable: true })] }) .mockResolvedValueOnce ({ rows: [buildPostImportRow ({ sourceRow: 1, attributes: { title: 'edited title', duration: '2' }, importStatus: 'pending', recoverable: true })] }) .mockResolvedValueOnce ({ created: 0, skipped: 0, failed: 1, rows: [{ sourceRow: 1, status: 'failed', recoverable: true, errors: { title: ['invalid'] } }] }) dialogue.form.mockImplementationOnce (async options => { let actions: DialogueFormAction[] = [] const controls: DialogueFormControls = { close: vi.fn (), confirm: vi.fn (), setActions: next => { actions = next } } render (options.body (controls)) await waitFor (() => expect (actions.length).toBe (2)) fireEvent.change (screen.getByDisplayValue ('old title'), { target: { value: 'edited title' } }) await actions.find (action => action.label === '編輯内容を保存')?.onSelect () }) renderReviewPage () fireEvent.click (screen.getByRole ('button', { name: '編輯' })) await waitFor (() => { const saved = loadPostImportSession () expect (saved?.rows[0]?.attributes.title).toBe ('edited title') expect (saved?.rows[0]?.importStatus).toBe ('pending') }) fireEvent.click (screen.getByRole ('button', { name: '取込実行' })) await waitFor (() => { expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.title) .toBe ('edited title') expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.duration) .toBe ('2') }) }) it ('retries only failed rows and does not resend created or skipped rows', async () => { savePostImportSession ({ source: 'https://example.com/post', repairMode: 'failed', rows: [ buildPostImportRow ({ sourceRow: 1, importStatus: 'created', createdPostId: 1 }), buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped', skipReason: 'existing', existingPostId: 2 }), buildPostImportRow ({ sourceRow: 3, importStatus: 'failed', recoverable: true, importErrors: { base: ['failed'] } })] }) api.apiPost .mockResolvedValueOnce ({ rows: [buildPostImportRow ({ sourceRow: 3, importStatus: 'pending', recoverable: true })] }) .mockResolvedValueOnce ({ created: 0, skipped: 1, failed: 0, rows: [{ sourceRow: 3, status: 'skipped', existingPostId: 9 }] }) renderReviewPage () fireEvent.click (screen.getByRole ('button', { name: '再試行' })) await waitFor (() => { expect (api.apiPost.mock.calls[1]?.[1]?.rows).toEqual ([ expect.objectContaining ({ sourceRow: 3 })]) }) }) it ('allows ready rows to be manually skipped and excludes them from API payloads', async () => { savePostImportSession ({ source: 'https://example.com/post', repairMode: 'all', rows: [buildPostImportRow ({ sourceRow: 1 })] }) renderReviewPage () fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' })) await waitFor (() => { expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument () }) expect (api.apiPost).not.toHaveBeenCalled () }) it ('restores values and errors when manual skip is cleared', async () => { savePostImportSession ({ source: 'https://example.com/post', repairMode: 'all', rows: [buildPostImportRow ({ sourceRow: 1, status: 'error', attributes: { title: 'kept title' }, validationErrors: { title: ['invalid'] } })] }) renderReviewPage () const toggle = await screen.findByRole ('checkbox', { name: 'スキップ' }) fireEvent.click (toggle) fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' })) expect (screen.getByText ('kept title')).toBeInTheDocument () expect (screen.getByText ('invalid')).toBeInTheDocument () }) it ('moves existing skipped rows into the collapsed section', async () => { savePostImportSession ({ source: 'https://example.com/post', repairMode: 'all', rows: [ buildPostImportRow ({ sourceRow: 1, attributes: { title: 'existing row' }, skipReason: 'existing', existingPostId: 2 }), buildPostImportRow ({ sourceRow: 2, attributes: { title: 'normal row' } })] }) renderReviewPage () expect (screen.getByText ('既存投稿による自動スキップ 1件')).toBeInTheDocument () expect (screen.queryByText ('existing row')).not.toBeInTheDocument () expect (screen.getByText ('normal row')).toBeInTheDocument () fireEvent.click (screen.getByRole ('button', { name: '既存投稿による自動スキップ 1件' })) expect (screen.getByText ('existing row')).toBeInTheDocument () expect (screen.queryAllByRole ('button', { name: '編輯' })).toHaveLength (1) expect (screen.queryAllByRole ('checkbox', { name: 'スキップ' })).toHaveLength (1) }) it ('navigates to /posts when the last failed row completes', async () => { savePostImportSession ({ source: 'https://example.com/post', repairMode: 'failed', rows: [ buildPostImportRow ({ sourceRow: 1, importStatus: 'created', createdPostId: 1 }), buildPostImportRow ({ sourceRow: 2, importStatus: 'failed', recoverable: true, importErrors: { base: ['failed'] } })] }) api.apiPost .mockResolvedValueOnce ({ rows: [buildPostImportRow ({ sourceRow: 2, importStatus: 'pending', recoverable: true })] }) .mockResolvedValueOnce ({ created: 1, skipped: 0, failed: 0, rows: [{ sourceRow: 2, status: 'created', post: { id: 2 } }] }) renderReviewPage () fireEvent.click (screen.getByRole ('button', { name: '再試行' })) await waitFor (() => { expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument () }) }) it ('sorts recoverable pending validation rows to the top in repair mode', () => { savePostImportSession ({ source: '', repairMode: 'failed', rows: [ buildPostImportRow ({ sourceRow: 2, attributes: { title: 'normal row' } }), buildPostImportRow ({ sourceRow: 1, attributes: { title: 'repair row' }, importStatus: 'pending', recoverable: true, validationErrors: { title: ['invalid'] } })] }) const { container } = renderReviewPage () const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map ( node => node.textContent) expect (titles[0]).toBe ('repair row') }) })