403 行
14 KiB
TypeScript
403 行
14 KiB
TypeScript
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 (
|
|
<HelmetProvider>
|
|
<MemoryRouter initialEntries={['/posts/import/result-session/result']}>
|
|
<Routes>
|
|
<Route
|
|
path="/posts/import/:sessionId/result"
|
|
element={<PostImportResultPage user={buildUser ()}/>}/>
|
|
</Routes>
|
|
</MemoryRouter>
|
|
</HelmetProvider>)
|
|
|
|
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 (
|
|
<HelmetProvider>
|
|
<MemoryRouter initialEntries={['/posts/import/result-hour-range/result']}>
|
|
<Routes>
|
|
<Route
|
|
path="/posts/import/:sessionId/result"
|
|
element={<PostImportResultPage user={buildUser ()}/>}/>
|
|
</Routes>
|
|
</MemoryRouter>
|
|
</HelmetProvider>)
|
|
|
|
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 (
|
|
<HelmetProvider>
|
|
<MemoryRouter initialEntries={['/posts/import/result-pending/result']}>
|
|
<Routes>
|
|
<Route
|
|
path="/posts/import/:sessionId/result"
|
|
element={<PostImportResultPage user={buildUser ()}/>}/>
|
|
</Routes>
|
|
</MemoryRouter>
|
|
</HelmetProvider>)
|
|
|
|
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 (
|
|
<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 (action => action.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)
|
|
})
|
|
})
|
|
|
|
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 (
|
|
<HelmetProvider>
|
|
<MemoryRouter initialEntries={['/posts/import/result-retry-missing-row/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-missing-row')
|
|
expect (saved?.rows[0]?.importStatus).toBe ('failed')
|
|
expect (toastApi.toast).toHaveBeenCalledWith (
|
|
expect.objectContaining ({ title: '登録結果が不完全でした' }))
|
|
})
|
|
})
|
|
})
|