このコミットが含まれているのは:
2026-07-16 19:35:21 +09:00
コミット d1de631eed
11個のファイルの変更396行の追加37行の削除
+216 -2
ファイルの表示
@@ -1,13 +1,23 @@
import { render, screen } from '@testing-library/react'
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 { savePostImportSession } from '@/lib/postImportSession'
import {
loadPostImportSession,
savePostImportSession,
} from '@/lib/postImportSession'
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories'
import type { PostImportRow } from '@/lib/postImportSession'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
const api = vi.hoisted (() => ({
apiPost: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
@@ -17,6 +27,7 @@ const dialogue = vi.hoisted (() => ({
}))
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/dialogues/useDialogue', () => ({
default: () => dialogue,
}))
@@ -103,4 +114,207 @@ describe ('PostImportResultPage', () => {
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 (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-busy/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
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 (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-success/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
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 (_1 => _1.label === '編輯内容を保存')?.onSelect ()
})
})
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-edit-retry/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
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 (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-retry-failure/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
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)
})
})
})
+25 -5
ファイルの表示
@@ -125,6 +125,15 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
const counts = useMemo (
() => resultSummaryCounts (session?.rows ?? []),
[session])
const busy = loadingRow != null
const persistSession = (nextSession: PostImportSession) => {
if (sessionId == null)
return
savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
}
const saveDraft = async (
row: PostImportRow,
@@ -187,13 +196,15 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
toast ({ title: '行の再検証結果が不完全でした' })
return { saved: false, row: null }
}
const rows = mergeValidatedImportRow (latestSession.rows, target)
const editedRows = replaceImportRow (latestSession.rows, nextRow)
const rows = mergeValidatedImportRow (editedRows, target)
const nextSession = {
...latestSession,
rows,
repairMode: resultRepairMode (rows) }
sessionRef.current = nextSession
setSession (nextSession)
persistSession (nextSession)
const mergedTarget = rows.find (_1 => _1.sourceRow === baseRow.sourceRow) ?? target
if (Object.keys (mergedTarget.validationErrors).length > 0)
return { saved: false, row: mergedTarget }
@@ -242,7 +253,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
}
const retry = async (sourceRow: number) => {
if (session == null || sessionId == null)
if (session == null || sessionId == null || loadingRow != null)
return
const initialSession = sessionRef.current
@@ -262,6 +273,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
const pendingSession = { ...initialSession, rows: pendingRows }
sessionRef.current = pendingSession
setSession (pendingSession)
persistSession (pendingSession)
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: pendingRows
.filter (row => row.importStatus !== 'created')
@@ -285,6 +297,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
toast ({ title: '再検証結果が不完全でした' })
return
}
@@ -298,6 +311,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
repairMode: resultRepairMode (validatedRows) }
sessionRef.current = nextSession
setSession (nextSession)
persistSession (nextSession)
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
if (target == null)
{
@@ -308,6 +322,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
toast ({ title: '再検証結果が不完全でした' })
return
}
@@ -356,6 +371,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
repairMode: resultRepairMode (nextRows) }
sessionRef.current = resultSession
setSession (resultSession)
persistSession (resultSession)
if (recoverableTarget != null
&& Object.keys (recoverableTarget.validationErrors).length > 0)
{
@@ -378,6 +394,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
persistSession (restoredSession)
}
toast ({ title: '再試行に失敗しました' })
}
@@ -389,7 +406,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
const openRepair = (sourceRow: number) => {
const currentSession = sessionRef.current
if (currentSession == null || sessionId == null)
if (currentSession == null || sessionId == null || loadingRow != null)
return
const nextSession = { ...currentSession, repairMode: 'failed' as const }
@@ -479,14 +496,15 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<Button
type="button"
variant="outline"
onClick={() => openRepair (row.sourceRow)}>
onClick={() => openRepair (row.sourceRow)}
disabled={busy}>
</Button>)}
{canRetry && (
<Button
type="button"
onClick={() => retry (row.sourceRow)}
disabled={loadingRow === row.sourceRow}>
disabled={busy}>
</Button>)}
</div>
@@ -498,6 +516,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => {
const currentSession = sessionRef.current
if (currentSession == null)
@@ -516,6 +535,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => {
clearPostImportSourceDraft (message =>
toast ({ title: '入力内容を削除できませんでした', description: message }))
+35
ファイルの表示
@@ -84,6 +84,11 @@ describe ('PostImportReviewPage', () => {
new Promise<{ rows: PostImportRow[] }> (resolve => {
resolveValidation = resolve
}))
api.apiPost.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 0,
rows: [] })
render (
<HelmetProvider>
@@ -106,4 +111,34 @@ describe ('PostImportReviewPage', () => {
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
})
it ('does not call import when validation omits a requested source row', async () => {
savePostImportSession ('review-missing-row', {
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 })] })
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-missing-row/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '再検証結果が不完全でした' }))
})
expect (api.apiPost).toHaveBeenCalledTimes (1)
})
})
+9
ファイルの表示
@@ -231,6 +231,15 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
metadataUrl: row.metadataUrl })),
changed_row: -1 })
const validatedRows = initialisePreviewRows (validated.rows)
const expectedSourceRows = currentSession.rows
.filter (row => row.importStatus !== 'created')
.map (row => row.sourceRow)
const validatedSourceRows = new Set (validatedRows.map (_1 => _1.sourceRow))
if (expectedSourceRows.some (_1 => !(validatedSourceRows.has (_1))))
{
toast ({ title: '再検証結果が不完全でした' })
return
}
const latestAfterValidate = sessionRef.current ?? currentSession
const mergedRows = mergeValidatedImportRows (latestAfterValidate.rows, validatedRows)
const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0)