import { act, 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 PostImportResultPage from '@/pages/posts/PostImportResultPage' import { buildUser } from '@/test/factories' import { buildPostImportRow } from '@/test/postImportFactories' import { originalCreatedAtString } from '@/lib/utils' import type { PostImportRow } from '@/lib/postImportSession' import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue' 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 ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/dialogues/useDialogue', () => ({ default: () => dialogue, })) describe ('PostImportResultPage', () => { 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 ('shows edit and retry only for recoverable failures and shows warnings', () => { savePostImportSession ('result-session', { source: '', repairMode: 'failed', rows: [ buildPostImportRow ({ sourceRow: 1, attributes: { title: 'recoverable row' }, importStatus: 'failed', recoverable: true, importErrors: { base: ['recoverable failed'] } }), buildPostImportRow ({ sourceRow: 2, attributes: { title: 'hard failed row' }, importStatus: 'failed', importErrors: { base: ['hard failed'] } }), buildPostImportRow ({ sourceRow: 3, attributes: { title: 'created row', originalCreatedFrom: '2024-01-01T00:00:00Z', originalCreatedBefore: '2024-01-02T00:00:00Z' }, importStatus: 'created', createdPostId: 3, fieldWarnings: { thumbnailBase: ['サムネール画像を取得できませんでした.'] } })] }) render ( }/> ) expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1) expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1) expect (screen.getByText ('サムネール画像を取得できませんでした.')) .toBeInTheDocument () expect (screen.getByText ( originalCreatedAtString ('2024-01-01T00:00:00Z', '2024-01-02T00:00:00Z'), )).toBeInTheDocument () }) it ('shows hour-precision original-created ranges with the shared utility output', () => { const from = '2024-01-01T12:00:00Z' const before = '2024-01-01T13:00:00Z' savePostImportSession ('result-hour-range', { source: '', repairMode: 'all', rows: [buildPostImportRow ({ sourceRow: 1, importStatus: 'created', createdPostId: 1, attributes: { title: 'created row', originalCreatedFrom: from, originalCreatedBefore: before } })] }) render ( }/> ) expect (screen.getByText (originalCreatedAtString (from, before))).toBeInTheDocument () }) it ('keeps recoverable pending rows actionable and hides actions for hard failures', () => { savePostImportSession ('result-pending', { source: '', repairMode: 'failed', rows: [ buildPostImportRow ({ sourceRow: 1, importStatus: 'pending', recoverable: true, validationErrors: { title: ['invalid'] } }), buildPostImportRow ({ sourceRow: 2, importStatus: 'pending', recoverable: true }), buildPostImportRow ({ sourceRow: 3, importStatus: 'failed', importErrors: { base: ['hard failed'] } })] }) render ( }/> ) expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1) expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1) expect (screen.getByText ('invalid')).toBeInTheDocument () expect (screen.getByText ('hard failed')).toBeInTheDocument () }) it ('disables editing, retry, and navigation while retry is running', async () => { let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null savePostImportSession ('result-retry-busy', { source: '', repairMode: 'failed', rows: [ buildPostImportRow ({ sourceRow: 1, importStatus: 'failed', recoverable: true, importErrors: { base: ['failed'] } }), buildPostImportRow ({ sourceRow: 2, importStatus: 'failed', recoverable: true, importErrors: { base: ['failed'] } })] }) api.apiPost.mockImplementationOnce (() => new Promise<{ rows: PostImportRow[] }> (resolve => { resolveValidation = resolve })) api.apiPost.mockResolvedValueOnce ({ created: 0, skipped: 0, failed: 0, rows: [] }) render ( }/> ) fireEvent.click (screen.getAllByRole ('button', { name: '再試行' })[0]) await waitFor (() => { screen.getAllByRole ('button', { name: '編輯' }).forEach (button => { expect (button).toBeDisabled () }) screen.getAllByRole ('button', { name: '再試行' }).forEach (button => { expect (button).toBeDisabled () }) expect (screen.getByRole ('button', { name: '確認画面へ戻る' })).toBeDisabled () expect (screen.getByRole ('button', { name: '新しい URL リストを入力' })).toBeDisabled () }) resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] }) }) it ('persists retry success to sessionStorage', async () => { savePostImportSession ('result-retry-success', { source: '', repairMode: 'failed', rows: [buildPostImportRow ({ sourceRow: 1, importStatus: 'failed', recoverable: true, importErrors: { base: ['failed'] } })] }) api.apiPost .mockResolvedValueOnce ({ rows: [buildPostImportRow ({ sourceRow: 1, importStatus: 'pending', recoverable: true })] }) .mockResolvedValueOnce ({ created: 1, skipped: 0, failed: 0, rows: [{ sourceRow: 1, status: 'created', post: { id: 10 } }] }) render ( }/> ) fireEvent.click (screen.getByRole ('button', { name: '再試行' })) await waitFor (() => { expect (loadPostImportSession ('result-retry-success')?.rows[0]?.importStatus) .toBe ('created') }) }) it ('keeps edited recoverable rows in session and retries with the edited values', async () => { savePostImportSession ('result-edit-retry', { source: '', repairMode: 'failed', rows: [buildPostImportRow ({ sourceRow: 1, attributes: { title: 'old title' }, importStatus: 'failed', recoverable: true, importErrors: { base: ['failed'] } })] }) api.apiPost .mockResolvedValueOnce ({ rows: [buildPostImportRow ({ sourceRow: 1, attributes: { title: 'edited title' }, importStatus: 'pending', recoverable: true })] }) .mockResolvedValueOnce ({ rows: [buildPostImportRow ({ sourceRow: 1, attributes: { title: 'edited title' }, importStatus: 'pending', recoverable: true })] }) .mockResolvedValueOnce ({ created: 1, skipped: 0, failed: 0, rows: [{ sourceRow: 1, status: 'created', post: { id: 11 } }] }) 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 act (async () => { await actions.find (action => action.label === '編輯内容を保存')?.onSelect () }) }) render ( }/> ) fireEvent.click (screen.getByRole ('button', { name: '編輯' })) await waitFor (() => { const saved = loadPostImportSession ('result-edit-retry') 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[2]?.[1]?.rows?.[0]?.attributes?.title) .toBe ('edited title') }) }) it ('persists restored failed state when retry fails', async () => { savePostImportSession ('result-retry-failure', { source: '', repairMode: 'failed', rows: [buildPostImportRow ({ sourceRow: 1, importStatus: 'failed', recoverable: true, importErrors: { base: ['failed'] } })] }) api.apiPost.mockRejectedValueOnce (new Error ('network error')) render ( }/> ) fireEvent.click (screen.getByRole ('button', { name: '再試行' })) await waitFor (() => { const saved = loadPostImportSession ('result-retry-failure') expect (saved?.rows[0]?.importStatus).toBe ('failed') expect (saved?.rows[0]?.recoverable).toBe (true) }) }) it ('restores the failed row when the retry import response is incomplete', async () => { savePostImportSession ('result-retry-missing-row', { source: '', repairMode: 'failed', rows: [buildPostImportRow ({ sourceRow: 1, importStatus: 'failed', recoverable: true, importErrors: { base: ['failed'] } })] }) api.apiPost .mockResolvedValueOnce ({ rows: [buildPostImportRow ({ sourceRow: 1, importStatus: 'pending', recoverable: true })] }) .mockResolvedValueOnce ({ created: 1, skipped: 0, failed: 0, rows: [] }) render ( }/> ) fireEvent.click (screen.getByRole ('button', { name: '再試行' })) await waitFor (() => { const saved = loadPostImportSession ('result-retry-missing-row') expect (saved?.rows[0]?.importStatus).toBe ('failed') expect (toastApi.toast).toHaveBeenCalledWith ( expect.objectContaining ({ title: '登録結果が不完全でした' })) }) }) })