このコミットが含まれているのは:
2026-07-16 12:39:35 +09:00
コミット 0224c4d2f4
14個のファイルの変更371行の追加130行の削除
+61 -8
ファイルの表示
@@ -2,6 +2,13 @@ class Post < ApplicationRecord
require 'mini_magick' require 'mini_magick'
require 'stringio' 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) def self.resized_thumbnail_attachment(upload)
upload.rewind upload.rewind
image = MiniMagick::Image.read(upload.read) image = MiniMagick::Image.read(upload.read)
@@ -143,16 +150,17 @@ class Post < ApplicationRecord
private private
def validate_original_created_range def validate_original_created_range
f = original_created_from f = parse_original_created_value(:original_created_from)
b = original_created_before b = parse_original_created_value(:original_created_before)
return if f.blank? || b.blank? return if f.nil? || b.nil?
f = Time.zone.parse(f) if String === f if b <= f
b = Time.zone.parse(b) if String === b errors.add :original_created_at, ORIGINAL_CREATED_ORDER_MESSAGE
return if !(f) || !(b) return
end
if f >= b if b - f < 1.minute
errors.add :original_created_at, 'オリジナルの作成日時の順番がをかしぃです.' errors.add :original_created_at, ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE
end end
end end
@@ -175,4 +183,49 @@ class Post < ApplicationRecord
self.url = PostUrlNormaliser.normalise(url) || url.strip self.url = PostUrlNormaliser.normalise(url) || url.strip
end 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 end
+1 -2
ファイルの表示
@@ -250,8 +250,7 @@ class PostImportPreviewer
def sanitise_metadata_time value def sanitise_metadata_time value
return nil unless value.is_a?(String) return nil unless value.is_a?(String)
time = Time.iso8601(value) Time.iso8601(value).in_time_zone.change(sec: 0, nsec: 0).iso8601
time.nsec.zero? ? time.iso8601 : time.iso8601(9)
rescue ArgumentError, TypeError rescue ArgumentError, TypeError
nil nil
end end
+6 -23
ファイルの表示
@@ -81,26 +81,16 @@ class PostMetadataFetcher
day = match[3].to_i day = match[3].to_i
hour = match[4].to_i hour = match[4].to_i
minute = match[5]&.to_i || 0 minute = match[5]&.to_i || 0
second = match[6]&.to_i || 0
fraction = match[7]
offset = match[8] offset = match[8]
whole_second = second + fractional_seconds(fraction) timestamp =
from =
if offset.present? 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 else
Time.zone.local(year, month, day, hour, minute, whole_second) Time.zone.local(year, month, day, hour, minute)
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
end end
from = timestamp.change(sec: 0, nsec: 0)
before = from + 1.minute
[from, before] [from, before]
end end
@@ -110,12 +100,6 @@ class PostMetadataFetcher
value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }" value.match?(/\A[+-]\d{2}:\d{2}\z/) ? value : "#{ value[0, 3] }:#{ value[3, 2] }"
end end
def self.fractional_seconds value
return 0 if value.blank?
Rational(value.to_i, 10**value.length)
end
def self.serialise_time value def self.serialise_time value
return nil if value.nil? return nil if value.nil?
@@ -126,6 +110,5 @@ class PostMetadataFetcher
:original_created_range, :original_created_range,
:parse_timestamp_range, :parse_timestamp_range,
:parse_offset, :parse_offset,
:fractional_seconds,
:serialise_time :serialise_time
end end
+26
ファイルの表示
@@ -84,6 +84,32 @@ RSpec.describe 'Post imports API', type: :request do
expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:bad_request)
expect(json.fetch('message')).to eq('取込行の形式が不正です.') expect(json.fetch('message')).to eq('取込行の形式が不正です.')
end 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 end
describe 'POST /posts/import' do describe 'POST /posts/import' do
+83
ファイルの表示
@@ -2206,6 +2206,89 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
end 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 end
describe 'tag versioning from post write actions' do describe 'tag versioning from post write actions' do
+4 -4
ファイルの表示
@@ -37,16 +37,16 @@ RSpec.describe PostMetadataFetcher do
) )
end 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') result = fetch_with_published_time('2024-02-03T12:34:56.123+02:30')
expect(Time.iso8601(result.fetch(:original_created_from))) 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( expect(
Time.iso8601(result.fetch(:original_created_before)) - Time.iso8601(result.fetch(:original_created_before)) -
Time.iso8601(result.fetch(:original_created_from)) Time.iso8601(result.fetch(:original_created_from))
).to eq(0.001) ).to eq(60)
expect(result.fetch(:original_created_from)).to include('.123000000') expect(result.fetch(:original_created_from)).not_to include('.')
end end
it 'adds platform tags for known video URLs' do it 'adds platform tags for known video URLs' do
+24 -3
ファイルの表示
@@ -20,8 +20,10 @@ describe ('PostOriginalCreatedTimeField', () => {
fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } }) fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } })
fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } }) fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } })
expect (setFrom).toHaveBeenCalledWith (expect.any (String)) expect (setFrom).toHaveBeenCalledWith (expect.stringMatching (
expect (setBefore).toHaveBeenCalledWith (expect.any (String)) /^\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', () => { it ('infers an exclusive before value on blur', () => {
@@ -38,7 +40,26 @@ describe ('PostOriginalCreatedTimeField', () => {
const input = screen.getAllByDisplayValue ('')[0] const input = screen.getAllByDisplayValue ('')[0]
fireEvent.blur (input, { target: { value: '2026-01-02T03:04' } }) 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 (
<PostOriginalCreatedTimeField
originalCreatedFrom="2026-01-01T00:00:05Z"
setOriginalCreatedFrom={setFrom}
originalCreatedBefore="2026-01-02T00:00:00.123Z"
setOriginalCreatedBefore={setBefore}/>,
)
expect (setFrom).toHaveBeenCalledWith ('2026-01-01T00:00Z')
expect (setBefore).toHaveBeenCalledWith ('2026-01-02T00:00Z')
}) })
it ('resets both values', () => { it ('resets both values', () => {
+88 -66
ファイルの表示
@@ -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 FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -19,72 +21,92 @@ const PostOriginalCreatedTimeField: FC<Props> = (
setOriginalCreatedFrom, setOriginalCreatedFrom,
originalCreatedBefore, originalCreatedBefore,
setOriginalCreatedBefore, setOriginalCreatedBefore,
errors }: Props) => ( errors }: Props) => {
<FormField label="オリジナルの作成日時" messages={errors}> useEffect (() => {
{({ describedBy, invalid }) => ( if (originalCreatedFrom == null)
<> return
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1">
<DateTimeField
className="w-full"
disabled={disabled ?? false}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
const d = new Date (v) const normalised = toMinutePrecisionIsoUtc (originalCreatedFrom)
if (d.getMinutes () === 0 && d.getHours () === 0) if (normalised !== originalCreatedFrom)
d.setDate (d.getDate () + 1) setOriginalCreatedFrom (normalised)
else }, [originalCreatedFrom, setOriginalCreatedFrom])
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (d.toISOString ())
}}/>
</div>
<div>
<Button
type="button"
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
</Button>
</div>
</div>
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start"> useEffect (() => {
<div className="min-w-0 flex-1"> if (originalCreatedBefore == null)
<DateTimeField return
className="w-full"
disabled={disabled} const normalised = toMinutePrecisionIsoUtc (originalCreatedBefore)
aria-describedby={describedBy} if (normalised !== originalCreatedBefore)
aria-invalid={invalid} setOriginalCreatedBefore (normalised)
invalid={invalid} }, [originalCreatedBefore, setOriginalCreatedBefore])
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/> return (
<FormField label="オリジナルの作成日時" messages={errors}>
</div> {({ describedBy, invalid }) => (
<div> <>
<Button <div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
type="button" <div className="min-w-0 flex-1">
className="bg-gray-600 text-white rounded" <DateTimeField
disabled={disabled} className="w-full"
onClick={() => { disabled={disabled ?? false}
setOriginalCreatedBefore (null) aria-describedby={describedBy}
}}> aria-invalid={invalid}
invalid={invalid}
</Button> value={originalCreatedFrom ?? undefined}
</div> onChange={setOriginalCreatedFrom}
</div> onBlur={ev => {
</>)} const v = ev.target.value
</FormField>) 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 ()))
}}/>
</div>
<div>
<Button
type="button"
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
</Button>
</div>
</div>
<div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1">
<DateTimeField
className="w-full"
disabled={disabled}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
</div>
<div>
<Button
type="button"
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
</Button>
</div>
</div>
</>)}
</FormField>)
}
export default PostOriginalCreatedTimeField export default PostOriginalCreatedTimeField
+2 -1
ファイルの表示
@@ -21,7 +21,8 @@ describe ('DateTimeField', () => {
fireEvent.change (input, { target: { value: '' } }) fireEvent.change (input, { target: { value: '' } })
const first = handleChange.mock.calls[0]?.[0] 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) expect (handleChange).toHaveBeenLastCalledWith (null)
}) })
}) })
+24 -8
ファイルの表示
@@ -8,13 +8,27 @@ import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react'
const pad = (n: number): string => n.toString ().padStart (2, '0') const pad = (n: number): string => n.toString ().padStart (2, '0')
const toDateTimeLocalValue = (d: Date) => { const toDateTimeLocalValue = (value: Date) => {
const y = d.getFullYear () const y = value.getFullYear ()
const m = pad (d.getMonth () + 1) const m = pad (value.getMonth () + 1)
const day = pad (d.getDate ()) const day = pad (value.getDate ())
const h = pad (d.getHours ()) const h = pad (value.getHours ())
const min = pad (d.getMinutes ()) const min = pad (value.getMinutes ())
return `${ y }-${ m }-${ day }T${ h }:${ min }:00` 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<Props> = ({ value, onChange, className, onBlur, invalid,
'focus:ring-2 focus:ring-blue-200']), 'focus:ring-2 focus:ring-blue-200']),
className)} className)}
type="datetime-local" type="datetime-local"
step={60}
value={local} value={local}
aria-invalid={invalid} aria-invalid={invalid}
onChange={ev => { onChange={ev => {
const v = ev.target.value const v = ev.target.value
setLocal (v) setLocal (v)
onChange?.(v ? (new Date (v)).toISOString () : null) onChange?.(v ? toMinutePrecisionIsoUtc (v) : null)
}} }}
onBlur={onBlur}/>) onBlur={onBlur}/>)
} }
export default DateTimeField export default DateTimeField
export { toMinutePrecisionIsoUtc }
+1 -1
ファイルの表示
@@ -52,7 +52,7 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') }) parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
const groupedMessages = (...values: (string[] | undefined)[]): string[] => const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
values.flatMap (value => value ?? []) [...new Set (values.flatMap (value => value ?? []))]
const sameDraft = (left: Draft, right: Draft): boolean => const sameDraft = (left: Draft, right: Draft): boolean =>
left.url === right.url left.url === right.url
+5 -5
ファイルの表示
@@ -1,5 +1,5 @@
import { Button } from '@/components/ui/button' 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 PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview' import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
@@ -19,9 +19,9 @@ const summaryWarning = (row: PostImportRow): string | null =>
?? null ?? null
const summaryDate = (row: PostImportRow): string => const summaryDate = (row: PostImportRow): string =>
[row.attributes.originalCreatedFrom, row.attributes.originalCreatedBefore] originalCreatedAtString (
.filter (_1 => typeof _1 === 'string' && _1 !== '') row.attributes.originalCreatedFrom?.toString () ?? null,
.join (' ~ ') row.attributes.originalCreatedBefore?.toString () ?? null)
const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => { const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
@@ -52,7 +52,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
{String (row.attributes.tags ?? '') || 'タグなし'} {String (row.attributes.tags ?? '') || 'タグなし'}
</div> </div>
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row) || '日時未取得'} {summaryDate (row)}
{row.attributes.duration ? ` / ${ row.attributes.duration }` : ''} {row.attributes.duration ? ` / ${ row.attributes.duration }` : ''}
</div> </div>
{warning && ( {warning && (
+27 -9
ファイルの表示
@@ -1,9 +1,10 @@
import { fireEvent, screen } from '@testing-library/react' 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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { savePostImportSession } from '@/lib/postImportSession' import { savePostImportSession } from '@/lib/postImportSession'
import PostImportResultPage from '@/pages/posts/PostImportResultPage' import PostImportResultPage from '@/pages/posts/PostImportResultPage'
import PageTitle from '@/components/common/PageTitle'
import { buildUser } from '@/test/factories' import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories' import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
@@ -14,17 +15,12 @@ const toastApi = vi.hoisted (() => ({ toast: vi.fn () }))
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/ui/use-toast', () => toastApi)
const ReviewLocation = () => {
const location = useLocation ()
return <div>{`review route ${ location.search }`}</div>
}
const renderPage = () => renderWithProviders ( const renderPage = () => renderWithProviders (
<Routes> <Routes>
<Route <Route
path="/posts/import/:sessionId/result" path="/posts/import/:sessionId/result"
element={<PostImportResultPage user={buildUser ()}/>}/> element={<PostImportResultPage user={buildUser ()}/>}/>
<Route path="/posts/import/:sessionId/review" element={<ReviewLocation/>}/> <Route path="/posts/import/:sessionId/review" element={<PageTitle>review route</PageTitle>}/>
</Routes>, </Routes>,
{ route: '/posts/import/session/result' }) { route: '/posts/import/session/result' })
@@ -54,7 +50,7 @@ describe ('PostImportResultPage', () => {
expect (screen.getByRole ('button', { name: '再試行' })).toBeInTheDocument () 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 ({ const failed = buildPostImportRow ({
importStatus: 'failed', importStatus: 'failed',
importErrors: { base: ['old error'] } }) importErrors: { base: ['old error'] } })
@@ -71,7 +67,8 @@ describe ('PostImportResultPage', () => {
renderPage () renderPage ()
fireEvent.click (await screen.findByRole ('button', { name: '再試行' })) 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) expect (api.apiPost).toHaveBeenCalledTimes (1)
const saved = JSON.parse ( const saved = JSON.parse (
sessionStorage.getItem ('post-import-session:session') ?? '{}') sessionStorage.getItem ('post-import-session:session') ?? '{}')
@@ -79,4 +76,25 @@ describe ('PostImportResultPage', () => {
importStatus: 'pending', importStatus: 'pending',
validationErrors: { title: ['タイトルを確認してください.'] } }) 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)
})
}) })
+19
ファイルの表示
@@ -75,4 +75,23 @@ describe ('PostImportReviewPage', () => {
skipReason: 'existing' }) 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)
})
}) })