From 95c3f08a44ec1c00eba4e75403cb42abbf7e1ced Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 16 Jul 2026 18:52:07 +0900 Subject: [PATCH] #399 --- backend/spec/tasks/nico_sync_spec.rb | 35 ++++- frontend/src/lib/postImportRows.test.ts | 60 +++++++- frontend/src/lib/postImportRows.ts | 42 +++++- frontend/src/lib/postImportStorage.test.ts | 18 ++- frontend/src/lib/postImportStorage.ts | 12 +- frontend/src/lib/postImportTypes.ts | 3 +- .../pages/posts/PostImportResultPage.test.tsx | 130 +++++++----------- .../src/pages/posts/PostImportResultPage.tsx | 63 +++++++-- .../pages/posts/PostImportReviewPage.test.tsx | 123 +++++++---------- .../src/pages/posts/PostImportReviewPage.tsx | 33 ++--- frontend/src/test/postImportFactories.ts | 1 + 11 files changed, 317 insertions(+), 203 deletions(-) diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index 944417c..fb71e4a 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -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( + '')) + 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") diff --git a/frontend/src/lib/postImportRows.test.ts b/frontend/src/lib/postImportRows.test.ts index aad4b67..1823c36 100644 --- a/frontend/src/lib/postImportRows.test.ts +++ b/frontend/src/lib/postImportRows.test.ts @@ -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] diff --git a/frontend/src/lib/postImportRows.ts b/frontend/src/lib/postImportRows.ts index 17e9ec8..8edff6f 100644 --- a/frontend/src/lib/postImportRows.ts +++ b/frontend/src/lib/postImportRows.ts @@ -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) diff --git a/frontend/src/lib/postImportStorage.test.ts b/frontend/src/lib/postImportStorage.test.ts index 9bed899..996ffec 100644 --- a/frontend/src/lib/postImportStorage.test.ts +++ b/frontend/src/lib/postImportStorage.test.ts @@ -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 ()) { diff --git a/frontend/src/lib/postImportStorage.ts b/frontend/src/lib/postImportStorage.ts index 738c9a2..a50f1fd 100644 --- a/frontend/src/lib/postImportStorage.ts +++ b/frontend/src/lib/postImportStorage.ts @@ -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 } } diff --git a/frontend/src/lib/postImportTypes.ts b/frontend/src/lib/postImportTypes.ts index ef67832..dec6f9e 100644 --- a/frontend/src/lib/postImportTypes.ts +++ b/frontend/src/lib/postImportTypes.ts @@ -34,7 +34,8 @@ export type PostImportRow = { metadataUrl?: string resetSnapshot: PostImportResetSnapshot createdPostId?: number - importStatus?: PostImportStatus } + importStatus?: PostImportStatus + recoverable?: boolean } export type PostImportResultRow = | { diff --git a/frontend/src/pages/posts/PostImportResultPage.test.tsx b/frontend/src/pages/posts/PostImportResultPage.test.tsx index c7e8756..467de30 100644 --- a/frontend/src/pages/posts/PostImportResultPage.test.tsx +++ b/frontend/src/pages/posts/PostImportResultPage.test.tsx @@ -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 ( - - }/> - review route}/> - , - { 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 ( + + + + }/> + + + ) - 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 () }) }) diff --git a/frontend/src/pages/posts/PostImportResultPage.tsx b/frontend/src/pages/posts/PostImportResultPage.tsx index e491eba..8021ff7 100644 --- a/frontend/src/pages/posts/PostImportResultPage.tsx +++ b/frontend/src/pages/posts/PostImportResultPage.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ user }) => { return { ...row, importStatus: 'pending', + recoverable: true, validationErrors: recoverable.errors ?? { }, importErrors: undefined } }) @@ -307,8 +330,17 @@ const PostImportResultPage: FC = ({ 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 = ({ 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 (
= ({ user }) => {
{row.url}
+
diff --git a/frontend/src/pages/posts/PostImportReviewPage.test.tsx b/frontend/src/pages/posts/PostImportReviewPage.test.tsx index 447277a..bf9b899 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.test.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.test.tsx @@ -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 ( - - }/> - result route}/> - , - { 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 ( + + + + }/> + RESULT ROUTE}/> + + + ) + + 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 () }) }) diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx index 08de705..930cdd1 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 })) diff --git a/frontend/src/test/postImportFactories.ts b/frontend/src/test/postImportFactories.ts index cc6a10a..5c07e7a 100644 --- a/frontend/src/test/postImportFactories.ts +++ b/frontend/src/test/postImportFactories.ts @@ -40,6 +40,7 @@ export const buildPostImportRow = ( provenance, tagSources, status: overrides.status ?? 'ready', + recoverable: overrides.recoverable, resetSnapshot: overrides.resetSnapshot ?? { url, attributes: { ...attributes },