このコミットが含まれているのは:
2026-07-16 18:52:07 +09:00
コミット 95c3f08a44
11個のファイルの変更317行の追加203行の削除
+50 -80
ファイルの表示
@@ -1,28 +1,25 @@
import { fireEvent, screen } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { render, screen } 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 PostImportResultPage from '@/pages/posts/PostImportResultPage'
import PageTitle from '@/components/common/PageTitle'
import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ apiPost: vi.fn () }))
const toastApi = vi.hoisted (() => ({ toast: 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)
const renderPage = () => renderWithProviders (
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
<Route path="/posts/import/:sessionId/review" element={<PageTitle>review route</PageTitle>}/>
</Routes>,
{ route: '/posts/import/session/result' })
vi.mock ('@/lib/dialogues/useDialogue', () => ({
default: () => dialogue,
}))
describe ('PostImportResultPage', () => {
beforeEach (() => {
@@ -30,71 +27,44 @@ describe ('PostImportResultPage', () => {
vi.clearAllMocks ()
})
it ('shows exclusive result counts, post links, and failed-row actions', async () => {
const rows = [
buildPostImportRow ({ sourceRow: 1, importStatus: 'created', createdPostId: 11 }),
buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped',
skipReason: 'existing', existingPostId: 22 }),
buildPostImportRow ({ sourceRow: 3, importStatus: 'failed',
importErrors: { base: ['登録中に失敗しました.'] } })]
savePostImportSession ('session', { source: '', rows, repairMode: 'all' })
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' },
importStatus: 'created',
createdPostId: 3,
fieldWarnings: {
thumbnailBase: ['サムネール画像を取得できませんでした.'] } })] })
renderPage ()
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/result-session/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
expect (await screen.findByText (
/登録成功\s*1件.*スキップ\s*1件.*失敗\s*1件/)).toBeInTheDocument ()
expect (screen.getAllByRole ('link', { name: '投稿を開く' })
.map (_1 => _1.getAttribute ('href'))).toEqual (['/posts/11', '/posts/22'])
expect (screen.getByText ('登録中に失敗しました.')).toBeInTheDocument ()
expect (screen.getByRole ('button', { name: '編輯' })).toBeInTheDocument ()
expect (screen.getByRole ('button', { name: '再試行' })).toBeInTheDocument ()
})
it ('keeps the result route and opens the dialogue on retry validation error', async () => {
const failed = buildPostImportRow ({
importStatus: 'failed',
importErrors: { base: ['old error'] } })
const invalid = buildPostImportRow ({
importStatus: 'pending',
status: 'error',
validationErrors: { title: ['タイトルを確認してください.'] } })
savePostImportSession ('session', {
source: failed.url,
rows: [failed],
repairMode: 'all' })
api.apiPost.mockResolvedValue ({ rows: [invalid] })
renderPage ()
fireEvent.click (await screen.findByRole ('button', { name: '再試行' }))
expect (await screen.findByText ('投稿を編輯')).toBeInTheDocument ()
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
expect (api.apiPost).toHaveBeenCalledTimes (1)
const saved = JSON.parse (
sessionStorage.getItem ('post-import-session:session') ?? '{}')
expect (saved.rows[0]).toMatchObject ({
importStatus: 'pending',
validationErrors: { title: ['タイトルを確認してください.'] } })
})
it ('keeps the dialogue open when result-page save validation fails', async () => {
const row = buildPostImportRow ({
importStatus: 'failed',
importErrors: { base: ['old error'] } })
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'failed' })
api.apiPost.mockResolvedValue ({
rows: [buildPostImportRow ({
sourceRow: row.sourceRow,
validationErrors: {
originalCreatedFrom: ['オリジナルの作成日時は分単位で入力してください.'] } })] })
renderPage ()
fireEvent.click (await screen.findByRole ('button', { name: '編輯' }))
fireEvent.click (await screen.findByRole ('button', { name: '編輯内容を保存' }))
expect (await screen.findByText ('投稿を編輯')).toBeInTheDocument ()
expect (screen.getByText ('オリジナルの作成日時は分単位で入力してください.'))
.toBeInTheDocument ()
expect (api.apiPost).toHaveBeenCalledTimes (1)
expect (screen.getAllByRole ('button', { name: '編輯' })).toHaveLength (1)
expect (screen.getAllByRole ('button', { name: '再試行' })).toHaveLength (1)
expect (screen.getByText ('サムネール画像を取得できませんでした.'))
.toBeInTheDocument ()
})
})
+49 -14
ファイルの表示
@@ -3,6 +3,7 @@ import { Helmet } from 'react-helmet-async'
import { useNavigate, useParams } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning'
import PageTitle from '@/components/common/PageTitle'
import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
@@ -18,10 +19,13 @@ import { canEditContent } from '@/lib/users'
import { clearPostImportSourceDraft,
initialisePreviewRows,
loadPostImportSession,
mergeValidatedImportRow,
mergeImportResults,
mergeValidatedImportRows,
replaceImportRow,
resultRepairMode,
resultRowMessages,
resultRowWarnings,
resultSummaryCounts,
retryImportRow,
savePostImportSession } from '@/lib/postImportSession'
@@ -221,14 +225,26 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
}
const retry = async (sourceRow: number) => {
if (session == null || sessionId == null)
if (sessionId == null)
return
const initialSession = sessionRef.current
if (initialSession == null)
return
setLoadingRow (sourceRow)
const originalRow = initialSession.rows.find (_1 => _1.sourceRow === sourceRow)
if (originalRow == null)
{
setLoadingRow (null)
return
}
try
{
const pendingRows = retryImportRow (session.rows, sourceRow)
setSession ({ ...session, rows: pendingRows })
const pendingRows = retryImportRow (initialSession.rows, sourceRow)
const pendingSession = { ...initialSession, rows: pendingRows }
sessionRef.current = pendingSession
setSession (pendingSession)
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: pendingRows
.filter (row => row.importStatus !== 'created')
@@ -240,13 +256,18 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
changed_row: -1 })
const validatedRows = mergeValidatedImportRows (
pendingRows,
initialisePreviewRows (validated.rows))
const validatedTarget = initialisePreviewRows (validated.rows)
.find (_1 => _1.sourceRow === sourceRow)
if (validatedTarget == null)
return
const latestAfterValidate = sessionRef.current ?? pendingSession
const validatedRows = mergeValidatedImportRow (
latestAfterValidate.rows,
validatedTarget)
const nextSession = {
...session,
...latestAfterValidate,
rows: validatedRows,
repairMode: 'failed' as const }
repairMode: resultRepairMode (validatedRows) }
sessionRef.current = nextSession
setSession (nextSession)
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
@@ -273,7 +294,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
provenance: target.provenance,
tagSources: target.tagSources,
metadataUrl: target.metadataUrl }] })
const mergedRows = mergeImportResults (nextSession.rows, result.rows)
const latestAfterImport = sessionRef.current ?? nextSession
const mergedRows = mergeImportResults (latestAfterImport.rows, result.rows)
const recoverableRows = result.rows.filter (row =>
row.status === 'failed'
&& row.recoverable
@@ -285,6 +307,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
return {
...row,
importStatus: 'pending',
recoverable: true,
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
@@ -307,8 +330,17 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
}
catch
{
sessionRef.current = session
setSession (session)
const latest = sessionRef.current
if (latest != null)
{
const restoredRows = replaceImportRow (latest.rows, originalRow)
const restoredSession = {
...latest,
rows: restoredRows,
repairMode: resultRepairMode (restoredRows) }
sessionRef.current = restoredSession
setSession (restoredSession)
}
toast ({ title: '再試行に失敗しました' })
}
finally
@@ -368,10 +400,12 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
const displayStatus = displayPostImportStatus (row)
const hasValidationErrors = Object.keys (row.validationErrors).length > 0
const canEdit =
row.importStatus === 'failed'
|| (row.importStatus === 'pending' && hasValidationErrors)
row.recoverable === true
&& (row.importStatus === 'failed'
|| (row.importStatus === 'pending' && hasValidationErrors))
const canRetry =
(row.importStatus === 'failed' || row.importStatus === 'pending')
row.recoverable === true
&& (row.importStatus === 'failed' || row.importStatus === 'pending')
&& !(hasValidationErrors)
return (
<div
@@ -390,6 +424,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{row.url}
</div>
<FieldWarning messages={resultRowWarnings (row)}/>
<FieldError messages={resultRowMessages (row)}/>
</div>
+50 -73
ファイルの表示
@@ -1,27 +1,30 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
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 { savePostImportSession } from '@/lib/postImportSession'
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ apiPost: vi.fn () }))
const toastApi = vi.hoisted (() => ({ toast: vi.fn () }))
const api = vi.hoisted (() => ({
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)
const renderPage = () => renderWithProviders (
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route path="/posts/import/:sessionId/result" element={<div>result route</div>}/>
</Routes>,
{ route: '/posts/import/session/review' })
vi.mock ('@/lib/dialogues/useDialogue', () => ({
default: () => dialogue,
}))
describe ('PostImportReviewPage', () => {
beforeEach (() => {
@@ -29,69 +32,43 @@ describe ('PostImportReviewPage', () => {
vi.clearAllMocks ()
})
it ('keeps the review route and opens the first invalid row dialogue', async () => {
const row = buildPostImportRow ({ attributes: { title: 'title' } })
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'all' })
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ({
attributes: { title: 'title' },
status: 'error',
validationErrors: { title: ['タイトルを確認してください.'] } })] })
renderPage ()
fireEvent.click (await screen.findByRole ('button', { name: '取込実行' }))
expect (await screen.findByText ('投稿を編輯')).toBeInTheDocument ()
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
expect (api.apiPost).toHaveBeenCalledTimes (1)
expect (screen.queryByText ('result route')).not.toBeInTheDocument ()
})
it ('submits existing rows for a formal skipped result and opens result route', async () => {
const row = buildPostImportRow ({
skipReason: 'existing',
existingPostId: 10 })
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'all' })
it ('navigates to the result route after an initial recoverable failure', async () => {
savePostImportSession ('review-session', {
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost
.mockResolvedValueOnce ({ rows: [row] })
.mockResolvedValueOnce ({
created: 0,
skipped: 1,
failed: 0,
rows: [{ sourceRow: 1, status: 'skipped', existingPostId: 10 }] })
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 1,
rows: [{
sourceRow: 1,
status: 'failed',
recoverable: true,
errors: { title: ['invalid'] } }] })
renderPage ()
fireEvent.click (await screen.findByRole ('button', { name: '取込実行' }))
render (
<HelmetProvider>
<MemoryRouter initialEntries={['/posts/import/review-session/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route
path="/posts/import/:sessionId/result"
element={<div>RESULT ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
expect (await screen.findByText ('result route')).toBeInTheDocument ()
expect (api.apiPost).toHaveBeenCalledTimes (2)
expect (api.apiPost.mock.calls[1]?.[1]).toMatchObject ({
rows: [expect.objectContaining ({ sourceRow: 1, url: row.url })] })
await waitFor (() => {
const saved = JSON.parse (
sessionStorage.getItem ('post-import-session:session') ?? '{}')
expect (saved.rows[0]).toMatchObject ({
importStatus: 'skipped',
existingPostId: 10,
skipReason: 'existing' })
expect (screen.getByText ('RESULT ROUTE')).toBeInTheDocument ()
})
})
it ('keeps the dialogue open when save validation fails', async () => {
const row = buildPostImportRow ({ attributes: { title: 'title' } })
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'all' })
api.apiPost.mockResolvedValue ({
rows: [buildPostImportRow ({
sourceRow: row.sourceRow,
validationErrors: {
originalCreatedAt: ['オリジナルの作成日時の範囲は1分以上必要です.'] } })] })
renderPage ()
fireEvent.click (await screen.findByRole ('button', { name: '編輯' }))
fireEvent.click (await screen.findByRole ('button', { name: '編輯内容を保存' }))
expect (await screen.findByText ('投稿を編輯')).toBeInTheDocument ()
expect (screen.getByText ('オリジナルの作成日時の範囲は1分以上必要です.'))
.toBeInTheDocument ()
expect (api.apiPost).toHaveBeenCalledTimes (1)
expect (dialogue.form).not.toHaveBeenCalled ()
})
})
+12 -21
ファイルの表示
@@ -18,6 +18,7 @@ import { loadPostImportSession,
mergeImportResults,
mergeValidatedImportRows,
processableImportRows,
resultRepairMode,
reviewSummaryCounts,
savePostImportSession } from '@/lib/postImportSession'
import Forbidden from '@/pages/Forbidden'
@@ -191,7 +192,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
}
const submit = async () => {
if (sessionId == null || session == null || processable.length === 0)
const currentSession = sessionRef.current
if (sessionId == null || currentSession == null || processable.length === 0)
return
setLoading (true)
@@ -199,7 +201,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
{
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows:
session.rows
currentSession.rows
.filter (row => row.importStatus !== 'created')
.map (row => ({ sourceRow: row.sourceRow,
url: row.url,
@@ -209,11 +211,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
metadataUrl: row.metadataUrl })),
changed_row: -1 })
const validatedRows = initialisePreviewRows (validated.rows)
const mergedRows = mergeValidatedImportRows (session.rows, validatedRows)
const mergedRows = mergeValidatedImportRows (currentSession.rows, validatedRows)
const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0)
if (firstInvalid != null)
{
const nextSession = { ...session, rows: mergedRows }
const nextSession = { ...currentSession, rows: mergedRows }
sessionRef.current = nextSession
setSession (nextSession)
void editRow (firstInvalid)
@@ -244,26 +246,15 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
return {
...row,
importStatus: 'pending',
recoverable: true,
validationErrors: recoverable.errors ?? { },
importErrors: undefined }
})
const firstRecoverable = nextRows.find (row =>
Object.keys (row.validationErrors).length > 0
&& row.importStatus !== 'created')
if (firstRecoverable != null)
{
const repairSession = { ...session,
rows: nextRows,
repairMode: 'failed' as const }
sessionRef.current = repairSession
setSession (repairSession)
const saved = savePostImportSession (sessionId, repairSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (saved)
void editRow (firstRecoverable)
return
}
const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const }
const nextSession = {
...currentSession,
rows: nextRows,
repairMode: resultRepairMode (nextRows) }
sessionRef.current = nextSession
setSession (nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))