From d1de631eed11b621239508447369e6cb56e44a3a Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 16 Jul 2026 19:35:21 +0900 Subject: [PATCH] #399 --- backend/app/models/post.rb | 10 + backend/spec/models/post_spec.rb | 38 +++ backend/spec/requests/post_imports_spec.rb | 2 + backend/spec/requests/posts_spec.rb | 12 +- .../PostOriginalCreatedTimeField.test.tsx | 27 ++- .../PostOriginalCreatedTimeField.tsx | 20 -- .../posts/import/PostImportRowForm.test.tsx | 32 +++ .../pages/posts/PostImportResultPage.test.tsx | 218 +++++++++++++++++- .../src/pages/posts/PostImportResultPage.tsx | 30 ++- .../pages/posts/PostImportReviewPage.test.tsx | 35 +++ .../src/pages/posts/PostImportReviewPage.tsx | 9 + 11 files changed, 396 insertions(+), 37 deletions(-) diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 92b2d3e..cfbb50c 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -172,6 +172,8 @@ class Post < ApplicationRecord private def validate_original_created_range + return if skip_original_created_validation? + f = parse_original_created_value(:original_created_from) b = parse_original_created_value(:original_created_before) return if f.nil? || b.nil? @@ -251,4 +253,12 @@ class Post < ApplicationRecord def minute_precision_time? value value.sec.zero? && value.nsec.zero? end + + def skip_original_created_validation? + return false if new_record? + return false if will_save_change_to_original_created_from? + return false if will_save_change_to_original_created_before? + + true + end end diff --git a/backend/spec/models/post_spec.rb b/backend/spec/models/post_spec.rb index b82136e..085d426 100644 --- a/backend/spec/models/post_spec.rb +++ b/backend/spec/models/post_spec.rb @@ -244,6 +244,44 @@ RSpec.describe Post, type: :model do end describe 'original created datetime validation' do + it 'allows unrelated updates on persisted posts with second-bearing datetimes' do + post = described_class.create!(title: 'title', url: 'https://example.com/post') + post.update_columns( + original_created_from: Time.zone.parse('2024-01-01T12:34:30Z'), + original_created_before: Time.zone.parse('2024-01-01T12:35:30Z') + ) + + post.title = 'updated title' + + expect(post).to be_valid + expect { post.save! }.not_to raise_error + end + + it 'rejects second-bearing updates when the datetime field changes' do + post = described_class.create!(title: 'title', url: 'https://example.com/post') + + post.original_created_from = '2024-01-01T12:34:30Z' + + expect(post).to be_invalid + expect(post.errors[:original_created_from]).to eq( + [described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE] + ) + end + + it 'accepts fixing persisted datetimes to minute precision' do + post = described_class.create!(title: 'title', url: 'https://example.com/post') + post.update_columns( + original_created_from: Time.zone.parse('2024-01-01T12:34:30Z'), + original_created_before: Time.zone.parse('2024-01-01T12:35:30Z') + ) + + post.original_created_from = '2024-01-01T12:34Z' + post.original_created_before = '2024-01-01T12:35Z' + + expect(post).to be_valid + expect { post.save! }.not_to raise_error + end + it 'adds only the minute-precision error for second precision values' do post = described_class.new( title: 'title', diff --git a/backend/spec/requests/post_imports_spec.rb b/backend/spec/requests/post_imports_spec.rb index da715f3..1fe54b1 100644 --- a/backend/spec/requests/post_imports_spec.rb +++ b/backend/spec/requests/post_imports_spec.rb @@ -109,6 +109,8 @@ RSpec.describe 'Post imports API', type: :request do expect(json.fetch('rows').first.fetch('validation_errors')).to include( 'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.'] ) + expect(json.fetch('rows').first.fetch('validation_errors')) + .not_to have_key('original_created_at') end end diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index e9fb073..dfa3963 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -2221,6 +2221,7 @@ RSpec.describe 'Posts API', type: :request do expect(json.fetch('errors')).to include( 'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.'] ) + expect(json.fetch('errors')).not_to have_key('original_created_at') end it 'rejects fractional-second original created timestamps on POST /posts' do @@ -2237,22 +2238,23 @@ RSpec.describe 'Posts API', type: :request do expect(json.fetch('errors')).to include( 'original_created_before' => ['オリジナルの作成日時は分単位で入力してください.'] ) + expect(json.fetch('errors')).not_to have_key('original_created_at') end - it 'rejects original created ranges shorter than one minute on POST /posts' do + it 'rejects non-increasing original created ranges on POST /posts' do sign_in_as(member) post '/posts', params: post_write_params( - title: 'too short original created range', - url: 'https://example.com/too-short-original-created-range', + title: 'non-increasing original created range', + url: 'https://example.com/non-increasing-original-created-range', tags: 'spec_tag', thumbnail: dummy_upload, original_created_from: '2020-01-01T00:00Z', - original_created_before: '2020-01-01T00:00:30Z') + original_created_before: '2020-01-01T00:00Z') expect(response).to have_http_status(:unprocessable_entity) expect(json.fetch('errors')).to include( - 'original_created_at' => ['オリジナルの作成日時の範囲は1分以上必要です.'] + 'original_created_at' => ['オリジナルの作成日時の順番がをかしぃです.'] ) end diff --git a/frontend/src/components/PostOriginalCreatedTimeField.test.tsx b/frontend/src/components/PostOriginalCreatedTimeField.test.tsx index 5f22f99..dc6ba4b 100644 --- a/frontend/src/components/PostOriginalCreatedTimeField.test.tsx +++ b/frontend/src/components/PostOriginalCreatedTimeField.test.tsx @@ -46,20 +46,37 @@ describe ('PostOriginalCreatedTimeField', () => { .toBe (60_000) }) - it ('normalises second-bearing values to minute precision', () => { + it ('does not rewrite mounted values that only differ by offset notation', () => { const setFrom = vi.fn () const setBefore = vi.fn () render ( , ) - expect (setFrom).toHaveBeenCalledWith ('2026-01-01T00:00Z') - expect (setBefore).toHaveBeenCalledWith ('2026-01-02T00:00Z') + expect (setFrom).not.toHaveBeenCalled () + expect (setBefore).not.toHaveBeenCalled () + }) + + it ('emits minute-precision UTC values only when the user edits the input', () => { + const setFrom = vi.fn () + + render ( + , + ) + + const input = screen.getDisplayValue ('2024-01-01T12:34') + fireEvent.change (input, { target: { value: '2024-01-01T12:35' } }) + + expect (setFrom).toHaveBeenCalledWith ('2024-01-01T03:35Z') }) it ('resets both values', () => { diff --git a/frontend/src/components/PostOriginalCreatedTimeField.tsx b/frontend/src/components/PostOriginalCreatedTimeField.tsx index 6fac5f8..d0cac09 100644 --- a/frontend/src/components/PostOriginalCreatedTimeField.tsx +++ b/frontend/src/components/PostOriginalCreatedTimeField.tsx @@ -1,5 +1,3 @@ -import { useEffect } from 'react' - import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField' import FormField from '@/components/common/FormField' import { Button } from '@/components/ui/button' @@ -22,24 +20,6 @@ const PostOriginalCreatedTimeField: FC = ( originalCreatedBefore, setOriginalCreatedBefore, errors }: Props) => { - useEffect (() => { - if (originalCreatedFrom == null) - return - - const normalised = toMinutePrecisionIsoUtc (originalCreatedFrom) - if (normalised !== originalCreatedFrom) - setOriginalCreatedFrom (normalised) - }, [originalCreatedFrom, setOriginalCreatedFrom]) - - useEffect (() => { - if (originalCreatedBefore == null) - return - - const normalised = toMinutePrecisionIsoUtc (originalCreatedBefore) - if (normalised !== originalCreatedBefore) - setOriginalCreatedBefore (normalised) - }, [originalCreatedBefore, setOriginalCreatedBefore]) - return ( {({ describedBy, invalid }) => ( diff --git a/frontend/src/components/posts/import/PostImportRowForm.test.tsx b/frontend/src/components/posts/import/PostImportRowForm.test.tsx index 26b3c79..a80864c 100644 --- a/frontend/src/components/posts/import/PostImportRowForm.test.tsx +++ b/frontend/src/components/posts/import/PostImportRowForm.test.tsx @@ -88,4 +88,36 @@ describe ('PostImportRowForm', () => { expect (screen.getAllByRole ('textbox').filter ( _1 => _1.getAttribute ('aria-invalid') === 'true')).toHaveLength (3) }) + + it ('keeps untouched original created values unchanged in the save payload', async () => { + let actions: DialogueFormAction[] = [] + const row = buildPostImportRow ({ + attributes: { + originalCreatedFrom: '2024-01-01T12:34+09:00', + originalCreatedBefore: '2024-01-01T12:35+09:00' } }) + const controls: DialogueFormControls = { + close: vi.fn (), + confirm: vi.fn (), + setActions: next => { + actions = next + } } + const onSave = vi.fn ().mockResolvedValue ({ saved: true, row: null }) + + render ( + ) + await waitFor (() => expect (actions.length).toBe (2)) + + await act (async () => { + await actions.find (_1 => _1.label === '編輯内容を保存')?.onSelect () + }) + + expect (onSave).toHaveBeenCalledWith ({ + draft: expect.objectContaining ({ + originalCreatedFrom: '2024-01-01T12:34+09:00', + originalCreatedBefore: '2024-01-01T12:35+09:00' }), + resetRequested: false }) + }) }) diff --git a/frontend/src/pages/posts/PostImportResultPage.test.tsx b/frontend/src/pages/posts/PostImportResultPage.test.tsx index 7004866..a14e24b 100644 --- a/frontend/src/pages/posts/PostImportResultPage.test.tsx +++ b/frontend/src/pages/posts/PostImportResultPage.test.tsx @@ -1,13 +1,23 @@ -import { render, screen } from '@testing-library/react' +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 { savePostImportSession } from '@/lib/postImportSession' +import { + loadPostImportSession, + savePostImportSession, +} from '@/lib/postImportSession' import PostImportResultPage from '@/pages/posts/PostImportResultPage' import { buildUser } from '@/test/factories' import { buildPostImportRow } from '@/test/postImportFactories' +import type { PostImportRow } from '@/lib/postImportSession' +import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue' + +const api = vi.hoisted (() => ({ + apiPost: vi.fn (), +})) + const toastApi = vi.hoisted (() => ({ toast: vi.fn (), })) @@ -17,6 +27,7 @@ const dialogue = vi.hoisted (() => ({ })) vi.mock ('@/components/ui/use-toast', () => toastApi) +vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/dialogues/useDialogue', () => ({ default: () => dialogue, })) @@ -103,4 +114,207 @@ describe ('PostImportResultPage', () => { 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 ( + + + + }/> + + + ) + + 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 ( + + + + }/> + + + ) + + 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 (_1 => _1.label === '編輯内容を保存')?.onSelect () + }) + }) + + render ( + + + + }/> + + + ) + + 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 ( + + + + }/> + + + ) + + 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) + }) + }) }) diff --git a/frontend/src/pages/posts/PostImportResultPage.tsx b/frontend/src/pages/posts/PostImportResultPage.tsx index bd85c12..bda0bb6 100644 --- a/frontend/src/pages/posts/PostImportResultPage.tsx +++ b/frontend/src/pages/posts/PostImportResultPage.tsx @@ -125,6 +125,15 @@ const PostImportResultPage: FC = ({ user }) => { const counts = useMemo ( () => resultSummaryCounts (session?.rows ?? []), [session]) + const busy = loadingRow != null + + const persistSession = (nextSession: PostImportSession) => { + if (sessionId == null) + return + + savePostImportSession (sessionId, nextSession, message => + toast ({ title: '取込状態を保存できませんでした', description: message })) + } const saveDraft = async ( row: PostImportRow, @@ -187,13 +196,15 @@ const PostImportResultPage: FC = ({ user }) => { toast ({ title: '行の再検証結果が不完全でした' }) return { saved: false, row: null } } - const rows = mergeValidatedImportRow (latestSession.rows, target) + const editedRows = replaceImportRow (latestSession.rows, nextRow) + const rows = mergeValidatedImportRow (editedRows, target) const nextSession = { ...latestSession, rows, repairMode: resultRepairMode (rows) } sessionRef.current = nextSession setSession (nextSession) + persistSession (nextSession) const mergedTarget = rows.find (_1 => _1.sourceRow === baseRow.sourceRow) ?? target if (Object.keys (mergedTarget.validationErrors).length > 0) return { saved: false, row: mergedTarget } @@ -242,7 +253,7 @@ const PostImportResultPage: FC = ({ user }) => { } const retry = async (sourceRow: number) => { - if (session == null || sessionId == null) + if (session == null || sessionId == null || loadingRow != null) return const initialSession = sessionRef.current @@ -262,6 +273,7 @@ const PostImportResultPage: FC = ({ user }) => { const pendingSession = { ...initialSession, rows: pendingRows } sessionRef.current = pendingSession setSession (pendingSession) + persistSession (pendingSession) const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { rows: pendingRows .filter (row => row.importStatus !== 'created') @@ -285,6 +297,7 @@ const PostImportResultPage: FC = ({ user }) => { repairMode: resultRepairMode (restoredRows) } sessionRef.current = restoredSession setSession (restoredSession) + persistSession (restoredSession) toast ({ title: '再検証結果が不完全でした' }) return } @@ -298,6 +311,7 @@ const PostImportResultPage: FC = ({ user }) => { repairMode: resultRepairMode (validatedRows) } sessionRef.current = nextSession setSession (nextSession) + persistSession (nextSession) const target = validatedRows.find (_1 => _1.sourceRow === sourceRow) if (target == null) { @@ -308,6 +322,7 @@ const PostImportResultPage: FC = ({ user }) => { repairMode: resultRepairMode (restoredRows) } sessionRef.current = restoredSession setSession (restoredSession) + persistSession (restoredSession) toast ({ title: '再検証結果が不完全でした' }) return } @@ -356,6 +371,7 @@ const PostImportResultPage: FC = ({ user }) => { repairMode: resultRepairMode (nextRows) } sessionRef.current = resultSession setSession (resultSession) + persistSession (resultSession) if (recoverableTarget != null && Object.keys (recoverableTarget.validationErrors).length > 0) { @@ -378,6 +394,7 @@ const PostImportResultPage: FC = ({ user }) => { repairMode: resultRepairMode (restoredRows) } sessionRef.current = restoredSession setSession (restoredSession) + persistSession (restoredSession) } toast ({ title: '再試行に失敗しました' }) } @@ -389,7 +406,7 @@ const PostImportResultPage: FC = ({ user }) => { const openRepair = (sourceRow: number) => { const currentSession = sessionRef.current - if (currentSession == null || sessionId == null) + if (currentSession == null || sessionId == null || loadingRow != null) return const nextSession = { ...currentSession, repairMode: 'failed' as const } @@ -479,14 +496,15 @@ const PostImportResultPage: FC = ({ user }) => { )} {canRetry && ( )} @@ -498,6 +516,7 @@ const PostImportResultPage: FC = ({ user }) => {