From 0224c4d2f4e324e2be50b5563530ff3090a20585 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 16 Jul 2026 12:39:35 +0900 Subject: [PATCH] #399 --- backend/app/models/post.rb | 69 +++++++- backend/app/services/post_import_previewer.rb | 3 +- backend/app/services/post_metadata_fetcher.rb | 29 +--- backend/spec/requests/post_imports_spec.rb | 26 +++ backend/spec/requests/posts_spec.rb | 83 ++++++++++ .../services/post_metadata_fetcher_spec.rb | 8 +- .../PostOriginalCreatedTimeField.test.tsx | 27 ++- .../PostOriginalCreatedTimeField.tsx | 154 ++++++++++-------- .../components/common/DateTimeField.test.tsx | 3 +- .../src/components/common/DateTimeField.tsx | 32 +++- .../posts/import/PostImportRowForm.tsx | 2 +- .../posts/import/PostImportRowSummary.tsx | 10 +- .../pages/posts/PostImportResultPage.test.tsx | 36 +++- .../pages/posts/PostImportReviewPage.test.tsx | 19 +++ 14 files changed, 371 insertions(+), 130 deletions(-) diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 0697ee2..31e3d57 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -2,6 +2,13 @@ class Post < ApplicationRecord require 'mini_magick' require 'stringio' + ORIGINAL_CREATED_INVALID_MESSAGE = 'オリジナルの作成日時の形式が不正です.'.freeze + ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE = + 'オリジナルの作成日時は分単位で入力してください.'.freeze + ORIGINAL_CREATED_ORDER_MESSAGE = 'オリジナルの作成日時の順番がをかしぃです.'.freeze + ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE = + 'オリジナルの作成日時の範囲は1分以上必要です.'.freeze + def self.resized_thumbnail_attachment(upload) upload.rewind image = MiniMagick::Image.read(upload.read) @@ -143,16 +150,17 @@ class Post < ApplicationRecord private def validate_original_created_range - f = original_created_from - b = original_created_before - return if f.blank? || b.blank? + f = parse_original_created_value(:original_created_from) + b = parse_original_created_value(:original_created_before) + return if f.nil? || b.nil? - f = Time.zone.parse(f) if String === f - b = Time.zone.parse(b) if String === b - return if !(f) || !(b) + if b <= f + errors.add :original_created_at, ORIGINAL_CREATED_ORDER_MESSAGE + return + end - if f >= b - errors.add :original_created_at, 'オリジナルの作成日時の順番がをかしぃです.' + if b - f < 1.minute + errors.add :original_created_at, ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE end end @@ -175,4 +183,49 @@ class Post < ApplicationRecord self.url = PostUrlNormaliser.normalise(url) || url.strip end + + def parse_original_created_value field + raw_value = public_send("#{ field }_before_type_cast") + value = public_send(field) + return nil if raw_value.blank? && value.blank? + + time = + case raw_value + when String + parse_original_created_string(raw_value) + when Time, ActiveSupport::TimeWithZone + raw_value.in_time_zone + else + value&.in_time_zone + end + if time.nil? + errors.add field, ORIGINAL_CREATED_INVALID_MESSAGE + return nil + end + unless minute_precision_time?(time) + errors.add field, ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE + end + time + end + + def parse_original_created_string raw_value + value = raw_value.to_s.strip + return nil if value.blank? + + if value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}\z/) + return Time.zone.local(value[0, 4].to_i, + value[5, 2].to_i, + value[8, 2].to_i, + value[11, 2].to_i, + value[14, 2].to_i) + end + + Time.iso8601(value).in_time_zone + rescue ArgumentError, TypeError + nil + end + + def minute_precision_time? value + value.sec.zero? && value.nsec.zero? + end end diff --git a/backend/app/services/post_import_previewer.rb b/backend/app/services/post_import_previewer.rb index c85cf8f..44a1f6f 100644 --- a/backend/app/services/post_import_previewer.rb +++ b/backend/app/services/post_import_previewer.rb @@ -250,8 +250,7 @@ class PostImportPreviewer def sanitise_metadata_time value return nil unless value.is_a?(String) - time = Time.iso8601(value) - time.nsec.zero? ? time.iso8601 : time.iso8601(9) + Time.iso8601(value).in_time_zone.change(sec: 0, nsec: 0).iso8601 rescue ArgumentError, TypeError nil end diff --git a/backend/app/services/post_metadata_fetcher.rb b/backend/app/services/post_metadata_fetcher.rb index e8e1493..e1ac96a 100644 --- a/backend/app/services/post_metadata_fetcher.rb +++ b/backend/app/services/post_metadata_fetcher.rb @@ -81,26 +81,16 @@ class PostMetadataFetcher day = match[3].to_i hour = match[4].to_i minute = match[5]&.to_i || 0 - second = match[6]&.to_i || 0 - fraction = match[7] offset = match[8] - whole_second = second + fractional_seconds(fraction) - from = + timestamp = if offset.present? - Time.new(year, month, day, hour, minute, whole_second, parse_offset(offset)).in_time_zone + Time.new(year, month, day, hour, minute, 0, parse_offset(offset)).in_time_zone else - Time.zone.local(year, month, day, hour, minute, whole_second) - end - before = - if fraction.present? - from + (10**(-fraction.length)) - elsif match[6].present? - from + 1.second - elsif match[5].present? - from + 1.minute - else - from + 1.hour + Time.zone.local(year, month, day, hour, minute) end + + from = timestamp.change(sec: 0, nsec: 0) + before = from + 1.minute [from, before] end @@ -110,12 +100,6 @@ class PostMetadataFetcher value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }" end - def self.fractional_seconds value - return 0 if value.blank? - - Rational(value.to_i, 10**value.length) - end - def self.serialise_time value return nil if value.nil? @@ -126,6 +110,5 @@ class PostMetadataFetcher :original_created_range, :parse_timestamp_range, :parse_offset, - :fractional_seconds, :serialise_time end diff --git a/backend/spec/requests/post_imports_spec.rb b/backend/spec/requests/post_imports_spec.rb index b3c6ae8..da715f3 100644 --- a/backend/spec/requests/post_imports_spec.rb +++ b/backend/spec/requests/post_imports_spec.rb @@ -84,6 +84,32 @@ RSpec.describe 'Post imports API', type: :request do expect(response).to have_http_status(:bad_request) expect(json.fetch('message')).to eq('取込行の形式が不正です.') end + + it 'returns original created datetime validation errors for minute precision' do + sign_in_as(member) + + post '/posts/import/validate', params: { + rows: [{ + sourceRow: 1, + url: 'https://example.com/post', + metadataUrl: 'https://example.com/post', + attributes: { + originalCreatedFrom: '2020-01-01T00:00:30Z', + originalCreatedBefore: '2020-01-01T00:01Z' }, + provenance: { + url: 'manual', + originalCreatedFrom: 'manual', + originalCreatedBefore: 'manual' }, + tagSources: { automatic: '', manual: '' } + }], + changed_row: -1 + } + + expect(response).to have_http_status(:ok) + expect(json.fetch('rows').first.fetch('validation_errors')).to include( + 'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.'] + ) + end end describe 'POST /posts/import' do diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 752ac6a..e9fb073 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -2206,6 +2206,89 @@ RSpec.describe 'Posts API', type: :request do expect(response).to have_http_status(:unprocessable_entity) end + + it 'rejects second-bearing original created timestamps on POST /posts' do + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'invalid original created from', + url: 'https://example.com/invalid-original-created-from', + tags: 'spec_tag', + thumbnail: dummy_upload, + original_created_from: '2020-01-01T00:00:01Z') + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.'] + ) + end + + it 'rejects fractional-second original created timestamps on POST /posts' do + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'invalid original created before', + url: 'https://example.com/invalid-original-created-before', + tags: 'spec_tag', + thumbnail: dummy_upload, + original_created_before: '2020-01-01T00:00:00.123Z') + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'original_created_before' => ['オリジナルの作成日時は分単位で入力してください.'] + ) + end + + it 'rejects original created ranges shorter than one minute 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', + tags: 'spec_tag', + thumbnail: dummy_upload, + original_created_from: '2020-01-01T00:00Z', + original_created_before: '2020-01-01T00:00:30Z') + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'original_created_at' => ['オリジナルの作成日時の範囲は1分以上必要です.'] + ) + end + + it 'accepts original created ranges that are exactly one minute on POST /posts' do + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'valid original created range', + url: 'https://example.com/valid-original-created-range', + tags: 'spec_tag', + thumbnail: dummy_upload, + original_created_from: '2020-01-01T00:00Z', + original_created_before: '2020-01-01T00:01Z') + + expect(response).to have_http_status(:created) + expect(Time.iso8601(json.fetch('original_created_from'))) + .to eq(Time.iso8601('2020-01-01T00:00Z')) + expect(Time.iso8601(json.fetch('original_created_before'))) + .to eq(Time.iso8601('2020-01-01T00:01Z')) + end + + it 'rejects unparseable original created timestamps on PUT /posts/:id' do + sign_in_as(member) + base_version = create_post_version_for!(post_record) + + put "/posts/#{post_record.id}", params: post_write_params( + base_version_no: base_version.version_no, + title: 'updated title', + tags: 'spec_tag', + original_created_from: 'not-a-time') + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'original_created_from' => ['オリジナルの作成日時の形式が不正です.'] + ) + end end describe 'tag versioning from post write actions' do diff --git a/backend/spec/services/post_metadata_fetcher_spec.rb b/backend/spec/services/post_metadata_fetcher_spec.rb index 38e5d53..25d851e 100644 --- a/backend/spec/services/post_metadata_fetcher_spec.rb +++ b/backend/spec/services/post_metadata_fetcher_spec.rb @@ -37,16 +37,16 @@ RSpec.describe PostMetadataFetcher do ) end - it 'preserves an input offset and fractional-second precision' do + it 'rounds second and fractional-second timestamps down to one-minute ranges' do result = fetch_with_published_time('2024-02-03T12:34:56.123+02:30') expect(Time.iso8601(result.fetch(:original_created_from))) - .to eq(Time.iso8601('2024-02-03T12:34:56.123+02:30')) + .to eq(Time.iso8601('2024-02-03T12:34:00+02:30')) expect( Time.iso8601(result.fetch(:original_created_before)) - Time.iso8601(result.fetch(:original_created_from)) - ).to eq(0.001) - expect(result.fetch(:original_created_from)).to include('.123000000') + ).to eq(60) + expect(result.fetch(:original_created_from)).not_to include('.') end it 'adds platform tags for known video URLs' do diff --git a/frontend/src/components/PostOriginalCreatedTimeField.test.tsx b/frontend/src/components/PostOriginalCreatedTimeField.test.tsx index a061d65..5f22f99 100644 --- a/frontend/src/components/PostOriginalCreatedTimeField.test.tsx +++ b/frontend/src/components/PostOriginalCreatedTimeField.test.tsx @@ -20,8 +20,10 @@ describe ('PostOriginalCreatedTimeField', () => { fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } }) fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } }) - expect (setFrom).toHaveBeenCalledWith (expect.any (String)) - expect (setBefore).toHaveBeenCalledWith (expect.any (String)) + expect (setFrom).toHaveBeenCalledWith (expect.stringMatching ( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/)) + expect (setBefore).toHaveBeenCalledWith (expect.stringMatching ( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/)) }) it ('infers an exclusive before value on blur', () => { @@ -38,7 +40,26 @@ describe ('PostOriginalCreatedTimeField', () => { const input = screen.getAllByDisplayValue ('')[0] fireEvent.blur (input, { target: { value: '2026-01-02T03:04' } }) - expect (setBefore).toHaveBeenCalledWith (expect.any (String)) + const value = setBefore.mock.calls.at (-1)?.[0] + expect (value).toMatch (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/) + expect (new Date (value).getTime () - new Date ('2026-01-02T03:04').getTime ()) + .toBe (60_000) + }) + + it ('normalises second-bearing values to minute precision', () => { + const setFrom = vi.fn () + const setBefore = vi.fn () + + render ( + , + ) + + expect (setFrom).toHaveBeenCalledWith ('2026-01-01T00:00Z') + expect (setBefore).toHaveBeenCalledWith ('2026-01-02T00:00Z') }) it ('resets both values', () => { diff --git a/frontend/src/components/PostOriginalCreatedTimeField.tsx b/frontend/src/components/PostOriginalCreatedTimeField.tsx index 8fef4c9..6fac5f8 100644 --- a/frontend/src/components/PostOriginalCreatedTimeField.tsx +++ b/frontend/src/components/PostOriginalCreatedTimeField.tsx @@ -1,4 +1,6 @@ -import DateTimeField from '@/components/common/DateTimeField' +import { useEffect } from 'react' + +import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField' import FormField from '@/components/common/FormField' import { Button } from '@/components/ui/button' @@ -19,72 +21,92 @@ const PostOriginalCreatedTimeField: FC = ( setOriginalCreatedFrom, originalCreatedBefore, setOriginalCreatedBefore, - errors }: Props) => ( - - {({ describedBy, invalid }) => ( - <> -
-
- { - const v = ev.target.value - if (!(v)) - return + errors }: Props) => { + useEffect (() => { + if (originalCreatedFrom == null) + return - const d = new Date (v) - if (d.getMinutes () === 0 && d.getHours () === 0) - d.setDate (d.getDate () + 1) - else - d.setMinutes (d.getMinutes () + 1) - setOriginalCreatedBefore (d.toISOString ()) - }}/> - 以降 -
-
- -
-
+ 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 }) => ( + <> +
+
+ { + const v = ev.target.value + if (!(v)) + return + + const d = new Date (v) + if (d.getMinutes () === 0 && d.getHours () === 0) + d.setDate (d.getDate () + 1) + else + d.setMinutes (d.getMinutes () + 1) + setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ())) + }}/> + 以降 +
+
+ +
+
+ +
+
+ + より前 +
+
+ +
+
+ )} +
) +} export default PostOriginalCreatedTimeField diff --git a/frontend/src/components/common/DateTimeField.test.tsx b/frontend/src/components/common/DateTimeField.test.tsx index 19605c7..2bf9b75 100644 --- a/frontend/src/components/common/DateTimeField.test.tsx +++ b/frontend/src/components/common/DateTimeField.test.tsx @@ -21,7 +21,8 @@ describe ('DateTimeField', () => { fireEvent.change (input, { target: { value: '' } }) const first = handleChange.mock.calls[0]?.[0] - expect (new Date (first).getFullYear ()).toBe (2026) + expect (first).toMatch (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/) + expect (new Date (first).getUTCSeconds ()).toBe (0) expect (handleChange).toHaveBeenLastCalledWith (null) }) }) diff --git a/frontend/src/components/common/DateTimeField.tsx b/frontend/src/components/common/DateTimeField.tsx index 0cb2210..74fbec8 100644 --- a/frontend/src/components/common/DateTimeField.tsx +++ b/frontend/src/components/common/DateTimeField.tsx @@ -8,13 +8,27 @@ import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react' const pad = (n: number): string => n.toString ().padStart (2, '0') -const toDateTimeLocalValue = (d: Date) => { - const y = d.getFullYear () - const m = pad (d.getMonth () + 1) - const day = pad (d.getDate ()) - const h = pad (d.getHours ()) - const min = pad (d.getMinutes ()) - return `${ y }-${ m }-${ day }T${ h }:${ min }:00` +const toDateTimeLocalValue = (value: Date) => { + const y = value.getFullYear () + const m = pad (value.getMonth () + 1) + const day = pad (value.getDate ()) + const h = pad (value.getHours ()) + const min = pad (value.getMinutes ()) + return `${ y }-${ m }-${ day }T${ h }:${ min }` +} + + +const toMinutePrecisionIsoUtc = (value: string) => { + const date = new Date (value) + if (Number.isNaN (date.getTime ())) + return value + + const y = date.getUTCFullYear () + const m = pad (date.getUTCMonth () + 1) + const day = pad (date.getUTCDate ()) + const h = pad (date.getUTCHours ()) + const min = pad (date.getUTCMinutes ()) + return `${ y }-${ m }-${ day }T${ h }:${ min }Z` } @@ -47,14 +61,16 @@ const DateTimeField: FC = ({ value, onChange, className, onBlur, invalid, 'focus:ring-2 focus:ring-blue-200']), className)} type="datetime-local" + step={60} value={local} aria-invalid={invalid} onChange={ev => { const v = ev.target.value setLocal (v) - onChange?.(v ? (new Date (v)).toISOString () : null) + onChange?.(v ? toMinutePrecisionIsoUtc (v) : null) }} onBlur={onBlur}/>) } export default DateTimeField +export { toMinutePrecisionIsoUtc } diff --git a/frontend/src/components/posts/import/PostImportRowForm.tsx b/frontend/src/components/posts/import/PostImportRowForm.tsx index 187ac01..b1f8a30 100644 --- a/frontend/src/components/posts/import/PostImportRowForm.tsx +++ b/frontend/src/components/posts/import/PostImportRowForm.tsx @@ -52,7 +52,7 @@ const buildResetDraft = (row: PostImportRow): Draft => ({ parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') }) const groupedMessages = (...values: (string[] | undefined)[]): string[] => - values.flatMap (value => value ?? []) + [...new Set (values.flatMap (value => value ?? []))] const sameDraft = (left: Draft, right: Draft): boolean => left.url === right.url diff --git a/frontend/src/components/posts/import/PostImportRowSummary.tsx b/frontend/src/components/posts/import/PostImportRowSummary.tsx index 3a26766..4c7a0d0 100644 --- a/frontend/src/components/posts/import/PostImportRowSummary.tsx +++ b/frontend/src/components/posts/import/PostImportRowSummary.tsx @@ -1,5 +1,5 @@ import { Button } from '@/components/ui/button' -import { cn } from '@/lib/utils' +import { cn, originalCreatedAtString } from '@/lib/utils' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview' @@ -19,9 +19,9 @@ const summaryWarning = (row: PostImportRow): string | null => ?? null const summaryDate = (row: PostImportRow): string => - [row.attributes.originalCreatedFrom, row.attributes.originalCreatedBefore] - .filter (_1 => typeof _1 === 'string' && _1 !== '') - .join (' ~ ') + originalCreatedAtString ( + row.attributes.originalCreatedFrom?.toString () ?? null, + row.attributes.originalCreatedBefore?.toString () ?? null) const PostImportRowSummary: FC = ({ row, onEdit }) => { @@ -52,7 +52,7 @@ const PostImportRowSummary: FC = ({ row, onEdit }) => { {String (row.attributes.tags ?? '') || 'タグなし'}
- {summaryDate (row) || '日時未取得'} + {summaryDate (row)} {row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
{warning && ( diff --git a/frontend/src/pages/posts/PostImportResultPage.test.tsx b/frontend/src/pages/posts/PostImportResultPage.test.tsx index 4a07415..c7e8756 100644 --- a/frontend/src/pages/posts/PostImportResultPage.test.tsx +++ b/frontend/src/pages/posts/PostImportResultPage.test.tsx @@ -1,9 +1,10 @@ import { fireEvent, screen } from '@testing-library/react' -import { Route, Routes, useLocation } from 'react-router-dom' +import { 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' @@ -14,17 +15,12 @@ const toastApi = vi.hoisted (() => ({ toast: vi.fn () })) vi.mock ('@/lib/api', () => api) vi.mock ('@/components/ui/use-toast', () => toastApi) -const ReviewLocation = () => { - const location = useLocation () - return
{`review route ${ location.search }`}
-} - const renderPage = () => renderWithProviders ( }/> - }/> + review route}/> , { route: '/posts/import/session/result' }) @@ -54,7 +50,7 @@ describe ('PostImportResultPage', () => { expect (screen.getByRole ('button', { name: '再試行' })).toBeInTheDocument () }) - it ('returns a retry validation error to the review dialogue route', async () => { + it ('keeps the result route and opens the dialogue on retry validation error', async () => { const failed = buildPostImportRow ({ importStatus: 'failed', importErrors: { base: ['old error'] } }) @@ -71,7 +67,8 @@ describe ('PostImportResultPage', () => { renderPage () fireEvent.click (await screen.findByRole ('button', { name: '再試行' })) - expect (await screen.findByText ('review route ?edit=1')).toBeInTheDocument () + expect (await screen.findByText ('投稿を編輯')).toBeInTheDocument () + expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument () expect (api.apiPost).toHaveBeenCalledTimes (1) const saved = JSON.parse ( sessionStorage.getItem ('post-import-session:session') ?? '{}') @@ -79,4 +76,25 @@ describe ('PostImportResultPage', () => { 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) + }) }) diff --git a/frontend/src/pages/posts/PostImportReviewPage.test.tsx b/frontend/src/pages/posts/PostImportReviewPage.test.tsx index 3e6d409..447277a 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.test.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.test.tsx @@ -75,4 +75,23 @@ describe ('PostImportReviewPage', () => { skipReason: 'existing' }) }) }) + + 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) + }) })