449 行
15 KiB
TypeScript
449 行
15 KiB
TypeScript
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 (
|
|
<HelmetProvider>
|
|
<MemoryRouter initialEntries={[route]}>
|
|
<Routes>
|
|
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
|
|
<Route
|
|
path="/posts/new/review"
|
|
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
|
<Route path="/posts" element={<div>POSTS ROUTE</div>}/>
|
|
</Routes>
|
|
</MemoryRouter>
|
|
</HelmetProvider>)
|
|
|
|
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')
|
|
})
|
|
})
|