広場投稿追加画面の刷新 (#399) #413
@@ -18,7 +18,11 @@ RSpec.describe "nico:sync" do
|
||||
|
||||
it "既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける" do
|
||||
# 既存 post(正規表現で拾われるURL)
|
||||
post = Post.create!(title: "old", url: "https://www.nicovideo.jp/watch/sm9", uploaded_user: nil)
|
||||
post = Post.create!(
|
||||
title: "old",
|
||||
url: "https://www.nicovideo.jp/watch/sm9",
|
||||
uploaded_user: nil
|
||||
)
|
||||
|
||||
# 既存の非nicoタグ(kept_non_nico_ids)
|
||||
kept_general = create_tag!("spec_kept", category: "general")
|
||||
@@ -79,8 +83,35 @@ RSpec.describe "nico:sync" do
|
||||
run_rake_task('nico:sync')
|
||||
end
|
||||
|
||||
it 'サムネール取得失敗後も次回同期で再試行できる' do
|
||||
post = Post.create!(
|
||||
title: 'old',
|
||||
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||
uploaded_user: nil)
|
||||
Tag.bot
|
||||
Tag.tagme
|
||||
|
||||
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
|
||||
allow(URI).to receive(:open)
|
||||
.and_return(
|
||||
StringIO.new(
|
||||
'<meta name="thumbnail" content="https://example.com/thumb.jpg">'))
|
||||
expect(post).to receive(:attach_thumbnail_from_url!)
|
||||
.with('https://example.com/thumb.jpg')
|
||||
.twice
|
||||
.and_raise(Post::RemoteThumbnailFetchFailed, 'failed')
|
||||
|
||||
2.times do
|
||||
run_rake_task('nico:sync')
|
||||
end
|
||||
end
|
||||
|
||||
it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do
|
||||
post = Post.create!(title: "old", url: "https://www.nicovideo.jp/watch/sm9", uploaded_user: nil)
|
||||
post = Post.create!(
|
||||
title: "old",
|
||||
url: "https://www.nicovideo.jp/watch/sm9",
|
||||
uploaded_user: nil
|
||||
)
|
||||
|
||||
# 旧nicoタグ(今回の同期結果に含まれない)
|
||||
old_nico = create_tag!("nico:OLD", category: "nico")
|
||||
|
||||
@@ -3,8 +3,10 @@ import { describe, expect, it } from 'vitest'
|
||||
import { creatableImportRows,
|
||||
initialisePreviewRows,
|
||||
mergeImportResults,
|
||||
mergeValidatedImportRow,
|
||||
mergeValidatedImportRows,
|
||||
processableImportRows,
|
||||
replaceImportRow,
|
||||
resultRepairMode,
|
||||
resultRowMessages,
|
||||
resultSummaryCounts,
|
||||
@@ -84,13 +86,16 @@ describe ('post import row state', () => {
|
||||
it ('merges result states and clears incompatible post identifiers', () => {
|
||||
const created = mergeImportResults ([buildPostImportRow ({
|
||||
skipReason: 'existing',
|
||||
existingPostId: 2 })], [{
|
||||
existingPostId: 2,
|
||||
recoverable: true,
|
||||
importStatus: 'pending' })], [{
|
||||
sourceRow: 1,
|
||||
status: 'created',
|
||||
post: { id: 3 } }])[0]
|
||||
const skipped = mergeImportResults ([buildPostImportRow ({
|
||||
createdPostId: 3,
|
||||
importStatus: 'created' })], [{
|
||||
importStatus: 'created',
|
||||
recoverable: true })], [{
|
||||
sourceRow: 1,
|
||||
status: 'skipped',
|
||||
existingPostId: 4 }])[0]
|
||||
@@ -99,20 +104,24 @@ describe ('post import row state', () => {
|
||||
existingPostId: 4 })], [{
|
||||
sourceRow: 1,
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: { base: ['failure'] } }])[0]
|
||||
|
||||
expect (created).toMatchObject ({
|
||||
importStatus: 'created',
|
||||
createdPostId: 3,
|
||||
existingPostId: undefined,
|
||||
recoverable: undefined,
|
||||
skipReason: undefined })
|
||||
expect (skipped).toMatchObject ({
|
||||
importStatus: 'skipped',
|
||||
existingPostId: 4,
|
||||
createdPostId: undefined,
|
||||
recoverable: undefined,
|
||||
skipReason: 'existing' })
|
||||
expect (failed).toMatchObject ({
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
createdPostId: undefined,
|
||||
existingPostId: undefined,
|
||||
skipReason: undefined,
|
||||
@@ -125,12 +134,15 @@ describe ('post import row state', () => {
|
||||
buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped',
|
||||
existingPostId: 2, skipReason: 'existing' }),
|
||||
buildPostImportRow ({ sourceRow: 3, importStatus: 'failed',
|
||||
importErrors: { base: ['failed'] } })]
|
||||
recoverable: true, importErrors: { base: ['failed'] } }),
|
||||
buildPostImportRow ({ sourceRow: 4, importStatus: 'pending',
|
||||
recoverable: true, validationErrors: { base: ['failed'] } })]
|
||||
|
||||
expect (resultSummaryCounts (rows)).toEqual ({ created: 1, skipped: 1, failed: 1 })
|
||||
expect (resultSummaryCounts (rows)).toEqual ({ created: 1, skipped: 1, failed: 2 })
|
||||
expect (retryImportRow (rows, 3)[2]).toMatchObject ({
|
||||
importStatus: 'pending',
|
||||
importErrors: undefined })
|
||||
expect (retryImportRow (rows, 4)[3]).toBe (rows[3])
|
||||
})
|
||||
|
||||
it ('deduplicates messages and keeps repair mode only for repairable rows', () => {
|
||||
@@ -149,6 +161,46 @@ describe ('post import row state', () => {
|
||||
expect (resultRepairMode ([complete])).toBe ('all')
|
||||
})
|
||||
|
||||
it ('merges only the validated source row and preserves other row edits', () => {
|
||||
const edited = buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'edited row' },
|
||||
provenance: { title: 'manual' } })
|
||||
const other = buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
attributes: { title: 'keep me' },
|
||||
provenance: { title: 'manual' } })
|
||||
const validated = buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'validated row' } })
|
||||
|
||||
const result = mergeValidatedImportRow ([edited, other], validated)
|
||||
|
||||
expect (result[0]?.attributes.title).toBe ('validated row')
|
||||
expect (result[1]?.attributes.title).toBe ('keep me')
|
||||
})
|
||||
|
||||
it ('replaces only the targeted source row and preserves the others', () => {
|
||||
const original = buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })
|
||||
const other = buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
attributes: { title: 'keep edited row' } })
|
||||
const restored = buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })
|
||||
|
||||
const result = replaceImportRow ([original, other], restored)
|
||||
|
||||
expect (result[0]).toEqual (restored)
|
||||
expect (result[1]?.attributes.title).toBe ('keep edited row')
|
||||
})
|
||||
|
||||
it ('copies reset snapshot values instead of sharing mutable records', () => {
|
||||
const row = buildPostImportRow ({ fieldWarnings: { title: ['warning'] } })
|
||||
const initialised = initialisePreviewRows ([row])[0]
|
||||
|
||||
@@ -7,8 +7,12 @@ const hasSkipReason = (row: PostImportRow): boolean =>
|
||||
const hasValidationErrors = (row: PostImportRow): boolean =>
|
||||
Object.keys (row.validationErrors ?? { }).length > 0
|
||||
|
||||
const isRecoverableRow = (row: PostImportRow): boolean =>
|
||||
row.recoverable === true
|
||||
|
||||
const isRepairableImportStatus = (row: PostImportRow): boolean =>
|
||||
row.importStatus === 'failed' || row.importStatus === 'pending'
|
||||
isRecoverableRow (row)
|
||||
&& (row.importStatus === 'failed' || row.importStatus === 'pending')
|
||||
|
||||
const buildResetSnapshot = (row: PostImportRow) => ({
|
||||
url: row.url,
|
||||
@@ -63,7 +67,8 @@ export const resultSummaryCounts = (rows: PostImportRow[]) =>
|
||||
++counts.skipped
|
||||
return counts
|
||||
}
|
||||
if (row.importStatus === 'failed')
|
||||
if (row.importStatus === 'failed'
|
||||
|| (row.recoverable === true && row.importStatus === 'pending'))
|
||||
++counts.failed
|
||||
return counts
|
||||
},
|
||||
@@ -84,6 +89,32 @@ export const resultRowMessages = (row: PostImportRow): string[] =>
|
||||
...Object.values (row.importErrors ?? { }).flat ()])]
|
||||
|
||||
|
||||
export const resultRowWarnings = (row: PostImportRow): string[] =>
|
||||
[...new Set ([
|
||||
...Object.values (row.fieldWarnings ?? { }).flat (),
|
||||
...row.baseWarnings])]
|
||||
|
||||
|
||||
export const replaceImportRow = (
|
||||
rows: PostImportRow[],
|
||||
nextRow: PostImportRow,
|
||||
): PostImportRow[] =>
|
||||
rows.map (row => row.sourceRow === nextRow.sourceRow ? nextRow : row)
|
||||
|
||||
|
||||
export const mergeValidatedImportRow = (
|
||||
rows: PostImportRow[],
|
||||
validated: PostImportRow,
|
||||
): PostImportRow[] => {
|
||||
const current = rows.find (row => row.sourceRow === validated.sourceRow)
|
||||
if (current == null)
|
||||
return rows
|
||||
|
||||
const [merged] = mergeValidatedImportRows ([current], [validated])
|
||||
return merged == null ? rows : replaceImportRow (rows, merged)
|
||||
}
|
||||
|
||||
|
||||
export const mergeValidatedImportRows = (
|
||||
current: PostImportRow[],
|
||||
validated: PostImportRow[],
|
||||
@@ -150,6 +181,7 @@ export const mergeImportResults = (
|
||||
return {
|
||||
...row,
|
||||
importStatus: 'created',
|
||||
recoverable: undefined,
|
||||
skipReason: undefined,
|
||||
createdPostId: result.post.id,
|
||||
existingPostId: undefined,
|
||||
@@ -160,6 +192,7 @@ export const mergeImportResults = (
|
||||
return {
|
||||
...row,
|
||||
importStatus: 'skipped',
|
||||
recoverable: undefined,
|
||||
skipReason: 'existing',
|
||||
createdPostId: undefined,
|
||||
existingPostId: result.existingPostId,
|
||||
@@ -170,6 +203,7 @@ export const mergeImportResults = (
|
||||
return {
|
||||
...row,
|
||||
importStatus: 'failed',
|
||||
recoverable: result.recoverable === true ? true : undefined,
|
||||
skipReason: undefined,
|
||||
createdPostId: undefined,
|
||||
existingPostId: undefined,
|
||||
@@ -186,7 +220,9 @@ export const retryImportRow = (
|
||||
sourceRow: number,
|
||||
): PostImportRow[] =>
|
||||
rows.map (row =>
|
||||
row.sourceRow === sourceRow && row.importStatus === 'failed'
|
||||
row.sourceRow === sourceRow
|
||||
&& row.importStatus === 'failed'
|
||||
&& row.recoverable === true
|
||||
? { ...row, importStatus: 'pending', importErrors: undefined }
|
||||
: row)
|
||||
|
||||
|
||||
@@ -16,18 +16,25 @@ describe ('post import storage', () => {
|
||||
|
||||
it ('round-trips a valid session and source draft', () => {
|
||||
const row = buildPostImportRow ({
|
||||
recoverable: true,
|
||||
importStatus: 'pending',
|
||||
validationErrors: { title: ['invalid'] } })
|
||||
const skipped = buildPostImportRow ({
|
||||
importStatus: 'skipped',
|
||||
skipReason: 'existing',
|
||||
existingPostId: 10 })
|
||||
|
||||
expect (savePostImportSession ('session', {
|
||||
source: row.url,
|
||||
rows: [row],
|
||||
source: skipped.url,
|
||||
rows: [row, skipped],
|
||||
repairMode: 'all' })).toBe (true)
|
||||
expect (loadPostImportSession ('session')).toMatchObject ({
|
||||
version: 2,
|
||||
source: row.url,
|
||||
source: skipped.url,
|
||||
rows: [{
|
||||
recoverable: true,
|
||||
importStatus: 'pending' },
|
||||
{
|
||||
importStatus: 'skipped',
|
||||
skipReason: 'existing',
|
||||
existingPostId: 10 }] })
|
||||
@@ -49,7 +56,10 @@ describe ('post import storage', () => {
|
||||
{ ...session.rows[0], skipReason: 'existing', existingPostId: undefined },
|
||||
{ ...session.rows[0], existingPostId: 2, skipReason: undefined },
|
||||
{ ...session.rows[0], importStatus: 'created', createdPostId: undefined },
|
||||
{ ...session.rows[0], importStatus: 'failed', createdPostId: 3 }]
|
||||
{ ...session.rows[0], importStatus: 'failed', createdPostId: 3 },
|
||||
{ ...session.rows[0], importStatus: 'created', recoverable: true },
|
||||
{ ...session.rows[0], importStatus: 'skipped', recoverable: true },
|
||||
{ ...session.rows[0], recoverable: true }]
|
||||
|
||||
for (const [index, row] of invalidRows.entries ())
|
||||
{
|
||||
|
||||
@@ -202,6 +202,8 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
|
||||
return null
|
||||
if (value.skipReason != null && !(isValidSkipReason (value.skipReason)))
|
||||
return null
|
||||
if (value.recoverable != null && value.recoverable !== true)
|
||||
return null
|
||||
if (value.skipReason === 'existing' && !(isPositiveInteger (value.existingPostId)))
|
||||
return null
|
||||
if (value.skipReason !== 'existing' && value.existingPostId != null)
|
||||
@@ -210,6 +212,13 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
|
||||
return null
|
||||
if (value.importStatus !== 'created' && value.createdPostId != null)
|
||||
return null
|
||||
if (value.recoverable === true
|
||||
&& value.importStatus !== 'failed'
|
||||
&& value.importStatus !== 'pending')
|
||||
return null
|
||||
if ((value.importStatus === 'created' || value.importStatus === 'skipped')
|
||||
&& value.recoverable != null)
|
||||
return null
|
||||
|
||||
const validationErrors = ensureStringListRecord (value.validationErrors)
|
||||
const fieldWarnings = ensureStringListRecord (value.fieldWarnings)
|
||||
@@ -268,7 +277,8 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
|
||||
resetSnapshot,
|
||||
createdPostId:
|
||||
isPositiveInteger (value.createdPostId) ? Number (value.createdPostId) : undefined,
|
||||
importStatus: value.importStatus ?? undefined }
|
||||
importStatus: value.importStatus ?? undefined,
|
||||
recoverable: value.recoverable === true ? true : undefined }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ export type PostImportRow = {
|
||||
metadataUrl?: string
|
||||
resetSnapshot: PostImportResetSnapshot
|
||||
createdPostId?: number
|
||||
importStatus?: PostImportStatus }
|
||||
importStatus?: PostImportStatus
|
||||
recoverable?: boolean }
|
||||
|
||||
export type PostImportResultRow =
|
||||
| {
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -40,6 +40,7 @@ export const buildPostImportRow = (
|
||||
provenance,
|
||||
tagSources,
|
||||
status: overrides.status ?? 'ready',
|
||||
recoverable: overrides.recoverable,
|
||||
resetSnapshot: overrides.resetSnapshot ?? {
|
||||
url,
|
||||
attributes: { ...attributes },
|
||||
|
||||
新しい課題から参照
ユーザをブロックする