このコミットが含まれているのは:
@@ -0,0 +1,112 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Post imports API', type: :request do
|
||||||
|
let(:member) { create(:user, :member) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Preview::UrlSafety).to receive(:validate) do |url|
|
||||||
|
[URI.parse(url), ['8.8.8.8']]
|
||||||
|
end
|
||||||
|
allow(PostMetadataFetcher).to receive(:fetch).and_return(
|
||||||
|
title: 'fetched title',
|
||||||
|
thumbnail_base: nil,
|
||||||
|
tags: ''
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST /posts/import/preview' do
|
||||||
|
it 'requires a member' do
|
||||||
|
sign_out
|
||||||
|
post '/posts/import/preview', params: { source: 'https://example.com/post' }
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
|
||||||
|
sign_in_as(create(:user, :guest))
|
||||||
|
post '/posts/import/preview', params: { source: 'https://example.com/post' }
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'parses a URL list and returns preview rows' do
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
post '/posts/import/preview', params: {
|
||||||
|
source: " https://example.com/one \r\n\r\nhttps://example.com/two"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.fetch('rows').map { _1.fetch('source_row') }).to eq([1, 3])
|
||||||
|
expect(json.fetch('rows').map { _1.fetch('url') }).to eq([
|
||||||
|
'https://example.com/one',
|
||||||
|
'https://example.com/two'
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns a safe 400 response for an invalid source' do
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
post '/posts/import/preview', params: { source: '' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:bad_request)
|
||||||
|
expect(json.fetch('message')).to eq('URL を入力してください.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST /posts/import/validate' do
|
||||||
|
it 'accepts camel-case row properties and returns their warnings' do
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
post '/posts/import/validate', params: {
|
||||||
|
rows: [{
|
||||||
|
sourceRow: '1',
|
||||||
|
url: 'https://example.com/post',
|
||||||
|
metadataUrl: 'https://example.com/post',
|
||||||
|
attributes: { title: 'manual title' },
|
||||||
|
provenance: { url: 'manual', title: 'manual' },
|
||||||
|
tagSources: { automatic: '', manual: '' },
|
||||||
|
fieldWarnings: { title: ['old warning'] },
|
||||||
|
baseWarnings: ['base warning']
|
||||||
|
}],
|
||||||
|
changed_row: -1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
result = json.fetch('rows').first
|
||||||
|
expect(result.fetch('source_row')).to eq(1)
|
||||||
|
expect(result.fetch('tag_sources')).to eq('automatic' => '', 'manual' => '')
|
||||||
|
expect(result.fetch('field_warnings')).to eq('title' => ['old warning'])
|
||||||
|
expect(result.fetch('base_warnings')).to eq(['base warning'])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects a non-array rows value with 400' do
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
post '/posts/import/validate', params: { rows: { sourceRow: 1 }, changed_row: -1 }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:bad_request)
|
||||||
|
expect(json.fetch('message')).to eq('取込行の形式が不正です.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST /posts/import' do
|
||||||
|
it 'returns a formal skipped result for an existing post' do
|
||||||
|
existing = create(:post, url: 'https://example.com/existing')
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
post '/posts/import', params: {
|
||||||
|
rows: [{
|
||||||
|
sourceRow: 1,
|
||||||
|
url: existing.url,
|
||||||
|
attributes: { title: 'ignored' },
|
||||||
|
provenance: { url: 'manual', title: 'manual' },
|
||||||
|
tagSources: { automatic: '', manual: '' }
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json).to include('created' => 0, 'skipped' => 1, 'failed' => 0)
|
||||||
|
expect(json.fetch('rows').first).to include(
|
||||||
|
'status' => 'skipped',
|
||||||
|
'existing_post_id' => existing.id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe PostImportPreviewer do
|
||||||
|
before do
|
||||||
|
allow(Preview::UrlSafety).to receive(:validate) do |url|
|
||||||
|
[URI.parse(url), ['8.8.8.8']]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def row(source_row:, url:, attributes: { }, provenance: { }, **values)
|
||||||
|
{
|
||||||
|
source_row:,
|
||||||
|
url:,
|
||||||
|
attributes:,
|
||||||
|
provenance:
|
||||||
|
}.merge(values)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#preview_rows' do
|
||||||
|
it 'marks an existing post for skipping without fetching metadata' do
|
||||||
|
existing = create(:post, url: 'https://example.com/existing')
|
||||||
|
expect(PostMetadataFetcher).not_to receive(:fetch)
|
||||||
|
|
||||||
|
result = described_class.new.preview_rows(rows: [
|
||||||
|
row(source_row: 1, url: 'https://EXAMPLE.com/existing/')
|
||||||
|
]).first
|
||||||
|
|
||||||
|
expect(result).to include(
|
||||||
|
url: 'https://example.com/existing',
|
||||||
|
skip_reason: 'existing',
|
||||||
|
existing_post_id: existing.id,
|
||||||
|
validation_errors: {}
|
||||||
|
)
|
||||||
|
expect(result.fetch(:field_warnings)).to eq({})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'reports list duplicates as URL errors instead of existing skips' do
|
||||||
|
create(:post, url: 'https://example.com/duplicate')
|
||||||
|
expect(PostMetadataFetcher).not_to receive(:fetch)
|
||||||
|
|
||||||
|
results = described_class.new.preview_rows(rows: [
|
||||||
|
row(source_row: 1, url: 'https://example.com/duplicate'),
|
||||||
|
row(source_row: 2, url: 'https://EXAMPLE.com/duplicate/')
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(results.map { _1[:validation_errors] }).to all(
|
||||||
|
include(url: ['URL が重複しています.'])
|
||||||
|
)
|
||||||
|
expect(results).to all(include(skip_reason: nil, existing_post_id: nil))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not fetch metadata for an unsafe URL' do
|
||||||
|
allow(Preview::UrlSafety).to receive(:validate)
|
||||||
|
.and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
|
||||||
|
expect(PostMetadataFetcher).not_to receive(:fetch)
|
||||||
|
|
||||||
|
result = described_class.new.preview_rows(rows: [
|
||||||
|
row(source_row: 1, url: 'https://unsafe.example/post')
|
||||||
|
]).first
|
||||||
|
|
||||||
|
expect(result.fetch(:validation_errors)).to include(
|
||||||
|
url: ['安全でない接続先は使用できません.']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'applies metadata to automatic fields and recognises metadata tags' do
|
||||||
|
Tag.create!(name: 'known-tag', category: :general)
|
||||||
|
allow(PostMetadataFetcher).to receive(:fetch).and_return(
|
||||||
|
title: 'metadata title',
|
||||||
|
thumbnail_base: 'https://example.com/thumb.jpg',
|
||||||
|
duration: 2_000,
|
||||||
|
tags: 'known-tag'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = described_class.new.preview_rows(rows: [
|
||||||
|
row(source_row: 1, url: 'https://example.com/new')
|
||||||
|
]).first
|
||||||
|
|
||||||
|
expect(result.fetch(:attributes)).to include(
|
||||||
|
'title' => 'metadata title',
|
||||||
|
'thumbnail_base' => 'https://example.com/thumb.jpg',
|
||||||
|
'duration' => 2_000,
|
||||||
|
'tags' => 'known-tag'
|
||||||
|
)
|
||||||
|
expect(result.fetch(:field_warnings)).not_to have_key('tags')
|
||||||
|
expect(PostMetadataFetcher).to have_received(:fetch).once
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'preserves manual empty values when metadata is available' do
|
||||||
|
allow(PostMetadataFetcher).to receive(:fetch).and_return(
|
||||||
|
title: 'metadata title',
|
||||||
|
tags: 'metadata-tag'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = described_class.new.preview_rows(rows: [
|
||||||
|
row(
|
||||||
|
source_row: 1,
|
||||||
|
url: 'https://example.com/manual',
|
||||||
|
attributes: { title: '', tags: '' },
|
||||||
|
provenance: { title: 'manual', tags: 'manual' },
|
||||||
|
tag_sources: { automatic: 'old-tag', manual: '' }
|
||||||
|
)
|
||||||
|
]).first
|
||||||
|
|
||||||
|
expect(result.fetch(:attributes)).to include('title' => '', 'tags' => '')
|
||||||
|
expect(result.fetch(:tag_sources)).to include('manual' => '')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'clears warnings derived from an old metadata URL' do
|
||||||
|
allow(PostMetadataFetcher).to receive(:fetch).and_return(
|
||||||
|
title: 'new title',
|
||||||
|
thumbnail_base: 'https://example.com/new-thumb.jpg',
|
||||||
|
tags: ''
|
||||||
|
)
|
||||||
|
|
||||||
|
result = described_class.new.preview_rows(rows: [
|
||||||
|
row(
|
||||||
|
source_row: 1,
|
||||||
|
url: 'https://example.com/new-url',
|
||||||
|
attributes: { title: 'old title', tags: 'old-tag' },
|
||||||
|
provenance: { title: 'automatic', tags: 'automatic' },
|
||||||
|
tag_sources: { automatic: 'old-tag', manual: '' },
|
||||||
|
metadata_url: 'https://example.com/old-url',
|
||||||
|
field_warnings: { tags: ['old tag warning'], url: ['old fetch warning'] },
|
||||||
|
base_warnings: ['old base warning']
|
||||||
|
)
|
||||||
|
]).first
|
||||||
|
|
||||||
|
expect(result.fetch(:attributes)).to include('title' => 'new title', 'tags' => '')
|
||||||
|
expect(result.fetch(:base_warnings)).to eq([])
|
||||||
|
expect(result.fetch(:field_warnings).values.flatten)
|
||||||
|
.not_to include('old tag warning', 'old fetch warning')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'isolates an unexpected metadata failure to the affected URL' do
|
||||||
|
allow(PostMetadataFetcher).to receive(:fetch) do |url|
|
||||||
|
raise 'fetch failure' if url.include?('failure')
|
||||||
|
|
||||||
|
{ title: 'successful title', tags: '' }
|
||||||
|
end
|
||||||
|
allow(Rails.logger).to receive(:error)
|
||||||
|
|
||||||
|
results = described_class.new.preview_rows(rows: [
|
||||||
|
row(source_row: 1, url: 'https://example.com/failure'),
|
||||||
|
row(source_row: 2, url: 'https://example.com/success')
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(results[0].fetch(:field_warnings)).to include(
|
||||||
|
'url' => [described_class::METADATA_FETCH_WARNING]
|
||||||
|
)
|
||||||
|
expect(results[1].fetch(:attributes)).to include('title' => 'successful title')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'turns an unsafe URL detected during metadata fetching into a URL error' do
|
||||||
|
allow(PostMetadataFetcher).to receive(:fetch)
|
||||||
|
.and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先です.')
|
||||||
|
allow(Rails.logger).to receive(:info)
|
||||||
|
|
||||||
|
result = described_class.new.preview_rows(rows: [
|
||||||
|
row(source_row: 1, url: 'https://example.com/redirects-to-private')
|
||||||
|
]).first
|
||||||
|
|
||||||
|
expect(result.fetch(:validation_errors)).to include(
|
||||||
|
url: ['安全でない接続先です.']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates parent IDs from the preloaded set' do
|
||||||
|
parent = create(:post)
|
||||||
|
|
||||||
|
results = described_class.new.preview_rows(
|
||||||
|
rows: [
|
||||||
|
row(source_row: 1, url: 'https://example.com/valid-parent',
|
||||||
|
attributes: { parent_post_ids: parent.id.to_s }),
|
||||||
|
row(source_row: 2, url: 'https://example.com/missing-parent',
|
||||||
|
attributes: { parent_post_ids: '999999' })
|
||||||
|
],
|
||||||
|
fetch_metadata: false
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(results[0].fetch(:validation_errors)).not_to have_key(:parent_post_ids)
|
||||||
|
expect(results[1].fetch(:validation_errors)).to include(
|
||||||
|
parent_post_ids: ['存在しない親投稿 Id. があります.']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe PostImportRowNormaliser do
|
||||||
|
def valid_row(overrides = { })
|
||||||
|
{
|
||||||
|
sourceRow: '1',
|
||||||
|
url: 'https://example.com/post',
|
||||||
|
metadataUrl: 'https://example.com/post',
|
||||||
|
attributes: { title: 'title', duration: 1_000 },
|
||||||
|
provenance: { url: 'manual', title: 'automatic' },
|
||||||
|
tagSources: { automatic: 'tag', manual: '' }
|
||||||
|
}.deep_merge(overrides)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.normalise!' do
|
||||||
|
it 'normalises aliases and source rows into a permitted plain hash' do
|
||||||
|
row = ActionController::Parameters.new(valid_row)
|
||||||
|
|
||||||
|
expect(described_class.normalise!([row])).to eq([
|
||||||
|
{
|
||||||
|
'source_row' => 1,
|
||||||
|
'url' => 'https://example.com/post',
|
||||||
|
'metadata_url' => 'https://example.com/post',
|
||||||
|
'attributes' => { 'title' => 'title', 'duration' => 1_000 },
|
||||||
|
'provenance' => { 'url' => 'manual', 'title' => 'automatic' },
|
||||||
|
'tag_sources' => { 'automatic' => 'tag', 'manual' => '' }
|
||||||
|
}
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'normalises source rows before checking duplicates' do
|
||||||
|
rows = [valid_row, valid_row(sourceRow: 1, url: 'https://example.com/other')]
|
||||||
|
|
||||||
|
expect { described_class.normalise!(rows) }
|
||||||
|
.to raise_error(ArgumentError, '元行番号が重複しています.')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects non-array batches and non-hash rows' do
|
||||||
|
expect { described_class.normalise!({}) }
|
||||||
|
.to raise_error(ArgumentError, '取込行の形式が不正です.')
|
||||||
|
expect { described_class.normalise!(['row']) }
|
||||||
|
.to raise_error(ArgumentError, '取込行の形式が不正です.')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects unknown attributes and invalid field types' do
|
||||||
|
expect { described_class.normalise!([valid_row(attributes: { unknown: 'x' })]) }
|
||||||
|
.to raise_error(ArgumentError, '取込項目が不正です.')
|
||||||
|
expect { described_class.normalise!([valid_row(attributes: { title: [] })]) }
|
||||||
|
.to raise_error(ArgumentError, '取込項目の型が不正です.')
|
||||||
|
expect { described_class.normalise!([valid_row(attributes: { duration: false })]) }
|
||||||
|
.to raise_error(ArgumentError, '取込項目の型が不正です.')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects unknown provenance and tag-source values' do
|
||||||
|
expect { described_class.normalise!([valid_row(provenance: { title: 'mapped' })]) }
|
||||||
|
.to raise_error(ArgumentError, '値の由来が不正です.')
|
||||||
|
expect { described_class.normalise!([valid_row(tagSources: { mapped: 'tag' })]) }
|
||||||
|
.to raise_error(ArgumentError, 'タグ由来の形式が不正です.')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'accepts warning fields only at the validation boundary' do
|
||||||
|
row = valid_row.merge(
|
||||||
|
fieldWarnings: { title: ['取得できませんでした.'] },
|
||||||
|
baseWarnings: ['確認してください.']
|
||||||
|
)
|
||||||
|
|
||||||
|
without_warnings = described_class.normalise!([row]).first
|
||||||
|
with_warnings = described_class.normalise!([row], allow_warning_fields: true).first
|
||||||
|
|
||||||
|
expect(without_warnings).not_to include('field_warnings', 'base_warnings')
|
||||||
|
expect(with_warnings).to include(
|
||||||
|
'field_warnings' => { 'title' => ['取得できませんでした.'] },
|
||||||
|
'base_warnings' => ['確認してください.']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe PostImportRunner do
|
||||||
|
let(:actor) { create(:user, :member) }
|
||||||
|
|
||||||
|
def row(source_row: 1, url: 'https://example.com/post')
|
||||||
|
{
|
||||||
|
sourceRow: source_row,
|
||||||
|
url:,
|
||||||
|
attributes: { title: 'title', tags: '' },
|
||||||
|
provenance: { url: 'manual', title: 'manual', tags: 'manual' },
|
||||||
|
tagSources: { automatic: '', manual: '' }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def preview(source_row: 1, errors: { }, skip_reason: nil, existing_post_id: nil)
|
||||||
|
{
|
||||||
|
source_row:,
|
||||||
|
attributes: { 'title' => 'title', 'tags' => '' },
|
||||||
|
validation_errors: errors,
|
||||||
|
skip_reason:,
|
||||||
|
existing_post_id:
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'previews the whole batch once before processing individual rows' do
|
||||||
|
rows = [row, row(source_row: 2, url: 'https://example.com/two')]
|
||||||
|
previewer = instance_double(PostImportPreviewer)
|
||||||
|
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
|
||||||
|
expect(previewer).to receive(:preview_rows)
|
||||||
|
.with(
|
||||||
|
rows: satisfy { _1.map { |row_value| row_value['source_row'] } == [1, 2] },
|
||||||
|
fetch_metadata: false
|
||||||
|
)
|
||||||
|
.and_return([preview, preview(source_row: 2)])
|
||||||
|
allow(PostCreator).to receive(:new).and_return(
|
||||||
|
instance_double(PostCreator, create!: create(:post))
|
||||||
|
)
|
||||||
|
|
||||||
|
result = described_class.new(actor:, rows:).run
|
||||||
|
|
||||||
|
expect(result).to include(created: 2, skipped: 0, failed: 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'treats validation errors as failures before an existing skip' do
|
||||||
|
existing = create(:post)
|
||||||
|
previewer = instance_double(PostImportPreviewer)
|
||||||
|
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
|
||||||
|
allow(previewer).to receive(:preview_rows).and_return([
|
||||||
|
preview(errors: { url: ['URL が重複しています.'] },
|
||||||
|
skip_reason: 'existing', existing_post_id: existing.id)
|
||||||
|
])
|
||||||
|
expect(PostCreator).not_to receive(:new)
|
||||||
|
|
||||||
|
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
|
||||||
|
|
||||||
|
expect(result).to include(
|
||||||
|
status: 'failed',
|
||||||
|
errors: { url: ['URL が重複しています.'] },
|
||||||
|
recoverable: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the existing post ID for skipped rows' do
|
||||||
|
existing = create(:post)
|
||||||
|
previewer = instance_double(PostImportPreviewer)
|
||||||
|
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
|
||||||
|
allow(previewer).to receive(:preview_rows).and_return([
|
||||||
|
preview(skip_reason: 'existing', existing_post_id: existing.id)
|
||||||
|
])
|
||||||
|
|
||||||
|
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
|
||||||
|
|
||||||
|
expect(result).to eq(
|
||||||
|
source_row: 1,
|
||||||
|
status: 'skipped',
|
||||||
|
existing_post_id: existing.id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'converts a URL uniqueness validation race into a skip' do
|
||||||
|
existing = create(:post, url: 'https://example.com/race')
|
||||||
|
invalid = Post.new(url: existing.url)
|
||||||
|
invalid.errors.add(:url, :taken)
|
||||||
|
previewer = instance_double(PostImportPreviewer)
|
||||||
|
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
|
||||||
|
allow(previewer).to receive(:preview_rows).and_return([preview])
|
||||||
|
creator = instance_double(PostCreator)
|
||||||
|
allow(PostCreator).to receive(:new).and_return(creator)
|
||||||
|
allow(creator).to receive(:create!).and_raise(ActiveRecord::RecordInvalid.new(invalid))
|
||||||
|
|
||||||
|
result = described_class.new(
|
||||||
|
actor:,
|
||||||
|
rows: [row(url: 'https://EXAMPLE.com/race/')]
|
||||||
|
).run.fetch(:rows).first
|
||||||
|
|
||||||
|
expect(result).to include(status: 'skipped', existing_post_id: existing.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 're-raises RecordNotUnique errors unrelated to the posts URL index' do
|
||||||
|
previewer = instance_double(PostImportPreviewer)
|
||||||
|
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
|
||||||
|
allow(previewer).to receive(:preview_rows).and_return([preview])
|
||||||
|
creator = instance_double(PostCreator)
|
||||||
|
allow(PostCreator).to receive(:new).and_return(creator)
|
||||||
|
allow(creator).to receive(:create!)
|
||||||
|
.and_raise(ActiveRecord::RecordNotUnique, 'other_unique_index')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.new(actor:, rows: [row]).run
|
||||||
|
}.to raise_error(ActiveRecord::RecordNotUnique)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'converts a posts URL index race into a skip' do
|
||||||
|
existing = create(:post, url: 'https://example.com/index-race')
|
||||||
|
previewer = instance_double(PostImportPreviewer)
|
||||||
|
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
|
||||||
|
allow(previewer).to receive(:preview_rows).and_return([preview])
|
||||||
|
creator = instance_double(PostCreator)
|
||||||
|
allow(PostCreator).to receive(:new).and_return(creator)
|
||||||
|
allow(creator).to receive(:create!).and_raise(
|
||||||
|
ActiveRecord::RecordNotUnique,
|
||||||
|
'duplicate key index_posts_on_url'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = described_class.new(
|
||||||
|
actor:,
|
||||||
|
rows: [row(url: 'https://EXAMPLE.com/index-race/')]
|
||||||
|
).run.fetch(:rows).first
|
||||||
|
|
||||||
|
expect(result).to include(status: 'skipped', existing_post_id: existing.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe PostImportUrlListParser do
|
||||||
|
describe '.parse' do
|
||||||
|
it 'trims URLs, ignores blank lines, and preserves source line numbers' do
|
||||||
|
source = " https://example.com/one \r\n\r\nhttps://example.com/two\n"
|
||||||
|
|
||||||
|
expect(described_class.parse(source)).to eq([
|
||||||
|
{ source_row: 1, url: 'https://example.com/one' },
|
||||||
|
{ source_row: 3, url: 'https://example.com/two' }
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects an empty URL list' do
|
||||||
|
expect { described_class.parse(" \n\r\n") }
|
||||||
|
.to raise_error(ArgumentError, 'URL を入力してください.')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects more than 100 non-empty rows' do
|
||||||
|
source = 101.times.map { |index| "https://example.com/#{ index }" }.join("\n")
|
||||||
|
|
||||||
|
expect { described_class.parse(source) }
|
||||||
|
.to raise_error(ArgumentError, '取込件数は 100 件までです.')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes the original line number in an oversized URL error' do
|
||||||
|
source = "\n#{ 'a' * (described_class::MAX_URL_BYTES + 1) }"
|
||||||
|
|
||||||
|
expect { described_class.parse(source) }
|
||||||
|
.to raise_error(ArgumentError, '2 行目: URL が長すぎます.')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects an oversized request before parsing rows' do
|
||||||
|
source = 'a' * (described_class::MAX_BYTES + 1)
|
||||||
|
|
||||||
|
expect { described_class.parse(source) }
|
||||||
|
.to raise_error(ArgumentError, '入力が大きすぎます.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe PostMetadataFetcher do
|
||||||
|
Response = Struct.new(:body)
|
||||||
|
|
||||||
|
def fetch_with_published_time(value, url: 'https://example.com/video')
|
||||||
|
html = <<~HTML
|
||||||
|
<html><head>
|
||||||
|
<meta property="article:published_time" content="#{ value }">
|
||||||
|
</head></html>
|
||||||
|
HTML
|
||||||
|
uri = URI.parse(url)
|
||||||
|
allow(Preview::UrlSafety).to receive(:validate).with(url).and_return([uri, ['8.8.8.8']])
|
||||||
|
allow(Preview::HttpFetcher).to receive(:fetch).and_return(Response.new(html))
|
||||||
|
allow(Preview::HtmlMetadataExtractor).to receive(:extract)
|
||||||
|
.and_return(title: 'title', image_url: nil)
|
||||||
|
|
||||||
|
described_class.fetch(url)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'builds ranges explicitly for year, month, and day precision' do
|
||||||
|
year = fetch_with_published_time('2024')
|
||||||
|
month = fetch_with_published_time('2024-02')
|
||||||
|
day = fetch_with_published_time('2024-02-03')
|
||||||
|
|
||||||
|
expect(year).to include(
|
||||||
|
original_created_from: Time.zone.local(2024, 1, 1).iso8601,
|
||||||
|
original_created_before: Time.zone.local(2025, 1, 1).iso8601
|
||||||
|
)
|
||||||
|
expect(month).to include(
|
||||||
|
original_created_from: Time.zone.local(2024, 2, 1).iso8601,
|
||||||
|
original_created_before: Time.zone.local(2024, 3, 1).iso8601
|
||||||
|
)
|
||||||
|
expect(day).to include(
|
||||||
|
original_created_from: Time.zone.local(2024, 2, 3).iso8601,
|
||||||
|
original_created_before: Time.zone.local(2024, 2, 4).iso8601
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'preserves an input offset and fractional-second precision' 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'))
|
||||||
|
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')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'adds platform tags for known video URLs' do
|
||||||
|
result = fetch_with_published_time('2024', url: 'https://youtu.be/abc123')
|
||||||
|
|
||||||
|
expect(result.fetch(:tags)).to eq('動画 YouTube')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns nil dates for an invalid timestamp' do
|
||||||
|
result = fetch_with_published_time('not-a-time')
|
||||||
|
|
||||||
|
expect(result.fetch(:original_created_from)).to be_nil
|
||||||
|
expect(result.fetch(:original_created_before)).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe PostUrlNormaliser do
|
||||||
|
before do
|
||||||
|
PostUrlSanitisationRule.unscoped.delete_all
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.normalise' do
|
||||||
|
it 'normalises the host and trailing slash before applying sanitisation rules' do
|
||||||
|
PostUrlSanitisationRule.create!(
|
||||||
|
priority: 10,
|
||||||
|
source_pattern: '\\Ahttps://example\\.com/source\\z',
|
||||||
|
replacement: 'https://example.com/canonical'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = described_class.normalise(' https://EXAMPLE.com/source/ ')
|
||||||
|
|
||||||
|
expect(result).to eq('https://example.com/canonical')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns nil for unsupported or malformed URLs' do
|
||||||
|
expect(described_class.normalise('ftp://example.com/file')).to be_nil
|
||||||
|
expect(described_class.normalise('https://[')).to be_nil
|
||||||
|
expect(described_class.normalise('https:/path')).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { menuOutline } from '@/components/TopNav'
|
||||||
|
import { buildUser } from '@/test/factories'
|
||||||
|
|
||||||
|
const submenuItem = (role: 'guest' | 'member' | 'admin', section: string, item: string) => {
|
||||||
|
const menu = menuOutline ({
|
||||||
|
user: buildUser ({ role }),
|
||||||
|
wikiId: section === 'Wiki' ? 10 : null,
|
||||||
|
pathName: section === 'Wiki' ? '/wiki/page' : '/posts' })
|
||||||
|
return menu.find (_1 => _1.name === section)?.subMenu.find (_1 => _1.name === item)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe ('menuOutline', () => {
|
||||||
|
it ('uses content-edit permission for post, material, and Wiki actions', () => {
|
||||||
|
for (const role of ['member', 'admin'] as const)
|
||||||
|
{
|
||||||
|
expect (submenuItem (role, '広場', '追加')?.visible).toBe (true)
|
||||||
|
expect (submenuItem (role, '広場', '取込')?.visible).toBe (true)
|
||||||
|
expect (submenuItem (role, '素材', '追加')?.visible).toBe (true)
|
||||||
|
expect (submenuItem (role, 'Wiki', '新規')?.visible).toBe (true)
|
||||||
|
expect (submenuItem (role, 'Wiki', '編輯')?.visible).toBe (true)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect (submenuItem ('guest', '広場', '追加')?.visible).toBe (false)
|
||||||
|
expect (submenuItem ('guest', '広場', '取込')?.visible).toBe (false)
|
||||||
|
expect (submenuItem ('guest', '素材', '追加')?.visible).toBe (false)
|
||||||
|
expect (submenuItem ('guest', 'Wiki', '新規')?.visible).toBe (false)
|
||||||
|
expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('keeps material suppression admin-only', () => {
|
||||||
|
expect (submenuItem ('member', '素材', '抑止')?.visible).toBe (false)
|
||||||
|
expect (submenuItem ('admin', '素材', '抑止')?.visible).toBe (true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
||||||
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||||
|
|
||||||
|
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||||
|
|
||||||
|
const FormBody = (
|
||||||
|
{ controls,
|
||||||
|
onSelect }: { controls: DialogueFormControls
|
||||||
|
onSelect: () => Promise<boolean> | boolean },
|
||||||
|
) => {
|
||||||
|
useEffect (() => {
|
||||||
|
controls.setActions ([{
|
||||||
|
label: '左操作',
|
||||||
|
placement: 'start',
|
||||||
|
onSelect }, {
|
||||||
|
label: '保存',
|
||||||
|
onSelect: () => true }])
|
||||||
|
}, [controls, onSelect])
|
||||||
|
|
||||||
|
return <div>長いフォーム本文</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const NestedConfirmFormBody = ({ controls }: { controls: DialogueFormControls }) => {
|
||||||
|
const reset = useCallback (async () => {
|
||||||
|
await controls.confirm ({
|
||||||
|
title: '変更をリセットしますか?',
|
||||||
|
confirmText: 'リセット' })
|
||||||
|
return false
|
||||||
|
}, [controls])
|
||||||
|
|
||||||
|
return <FormBody controls={controls} onSelect={reset}/>
|
||||||
|
}
|
||||||
|
|
||||||
|
describe ('DialogueProvider', () => {
|
||||||
|
it ('keeps ordinary dialogues in FIFO order', async () => {
|
||||||
|
const Launcher = () => {
|
||||||
|
const dialogue = useDialogue ()
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void dialogue.confirm ({ title: '一件目' })
|
||||||
|
void dialogue.confirm ({ title: '二件目' })
|
||||||
|
}}>
|
||||||
|
開く
|
||||||
|
</button>)
|
||||||
|
}
|
||||||
|
|
||||||
|
render (<DialogueProvider><Launcher/></DialogueProvider>)
|
||||||
|
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
|
||||||
|
|
||||||
|
expect (screen.getByText ('一件目')).toBeInTheDocument ()
|
||||||
|
expect (screen.queryByText ('二件目')).not.toBeInTheDocument ()
|
||||||
|
fireEvent.click (screen.getByRole ('button', { name: '確定' }))
|
||||||
|
expect (await screen.findByText ('二件目')).toBeInTheDocument ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('keeps a large form open when an action returns false', async () => {
|
||||||
|
const action = vi.fn ().mockResolvedValue (false)
|
||||||
|
const Launcher = () => {
|
||||||
|
const dialogue = useDialogue ()
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => void dialogue.form ({
|
||||||
|
title: '投稿を編輯',
|
||||||
|
description: '説明',
|
||||||
|
size: 'large',
|
||||||
|
body: controls => <FormBody controls={controls} onSelect={action}/> })}>
|
||||||
|
開く
|
||||||
|
</button>)
|
||||||
|
}
|
||||||
|
|
||||||
|
render (<DialogueProvider><Launcher/></DialogueProvider>)
|
||||||
|
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
|
||||||
|
|
||||||
|
const dialogue = screen.getByRole ('dialog')
|
||||||
|
expect (dialogue).toHaveClass ('max-h-[calc(100dvh-1rem)]', 'flex-col', 'max-w-3xl')
|
||||||
|
await waitFor (() => expect (screen.getByRole ('button', { name: '左操作' }))
|
||||||
|
.toBeInTheDocument ())
|
||||||
|
expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass ('w-full', 'sm:w-auto')
|
||||||
|
fireEvent.click (screen.getByRole ('button', { name: '左操作' }))
|
||||||
|
|
||||||
|
await waitFor (() => expect (action).toHaveBeenCalledTimes (1))
|
||||||
|
expect (screen.getByText ('投稿を編輯')).toBeInTheDocument ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('opens a nested confirmation over a form and returns to the same form', async () => {
|
||||||
|
const Launcher = () => {
|
||||||
|
const dialogue = useDialogue ()
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => void dialogue.form ({
|
||||||
|
title: '投稿を編輯',
|
||||||
|
body: controls => <NestedConfirmFormBody controls={controls}/> })}>
|
||||||
|
開く
|
||||||
|
</button>)
|
||||||
|
}
|
||||||
|
|
||||||
|
render (<DialogueProvider><Launcher/></DialogueProvider>)
|
||||||
|
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
|
||||||
|
const action = await screen.findByRole ('button', { name: '左操作' })
|
||||||
|
fireEvent.click (action)
|
||||||
|
|
||||||
|
expect (await screen.findByText ('変更をリセットしますか?')).toBeInTheDocument ()
|
||||||
|
fireEvent.click (screen.getByRole ('button', { name: 'リセット' }))
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (screen.queryByText ('変更をリセットしますか?')).not.toBeInTheDocument ()
|
||||||
|
})
|
||||||
|
expect (screen.getByText ('投稿を編輯')).toBeInTheDocument ()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
||||||
|
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||||
|
|
||||||
|
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||||
|
|
||||||
|
describe ('PostImportRowForm', () => {
|
||||||
|
it ('resets only the draft, then saves with resetRequested', async () => {
|
||||||
|
const row = buildPostImportRow ()
|
||||||
|
row.attributes.title = 'manual title'
|
||||||
|
row.provenance.title = 'manual'
|
||||||
|
const actions: DialogueFormAction[][] = []
|
||||||
|
const controls: DialogueFormControls = {
|
||||||
|
close: vi.fn (),
|
||||||
|
confirm: vi.fn ().mockResolvedValue (true),
|
||||||
|
setActions: next => actions.push (next) }
|
||||||
|
const invalidRow = buildPostImportRow ({
|
||||||
|
validationErrors: { title: ['タイトルを確認してください.'] } })
|
||||||
|
const onSave = vi.fn ().mockResolvedValue ({ saved: false, row: invalidRow })
|
||||||
|
|
||||||
|
render (<PostImportRowForm row={row} controls={controls} onSave={onSave}/>)
|
||||||
|
const titleInput = screen.getByDisplayValue ('manual title')
|
||||||
|
|
||||||
|
await waitFor (() => expect (actions.at (-1)?.length).toBe (2))
|
||||||
|
const reset = actions.at (-1)?.find (_1 => _1.label === '変更をリセット')
|
||||||
|
expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false })
|
||||||
|
await act (async () => {
|
||||||
|
await reset?.onSelect ()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect (controls.confirm).toHaveBeenCalled ()
|
||||||
|
expect (titleInput).toHaveValue ('')
|
||||||
|
expect (onSave).not.toHaveBeenCalled ()
|
||||||
|
|
||||||
|
const save = actions.at (-1)?.find (_1 => _1.label === '編輯内容を保存')
|
||||||
|
await act (async () => {
|
||||||
|
await save?.onSelect ()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect (onSave).toHaveBeenCalledWith ({
|
||||||
|
draft: expect.objectContaining ({ title: '' }),
|
||||||
|
resetRequested: true })
|
||||||
|
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('does not reset the draft when confirmation is cancelled', async () => {
|
||||||
|
const row = buildPostImportRow ({ attributes: { title: 'manual title' } })
|
||||||
|
let actions: DialogueFormAction[] = []
|
||||||
|
const controls: DialogueFormControls = {
|
||||||
|
close: vi.fn (),
|
||||||
|
confirm: vi.fn ().mockResolvedValue (false),
|
||||||
|
setActions: next => {
|
||||||
|
actions = next
|
||||||
|
} }
|
||||||
|
|
||||||
|
render (
|
||||||
|
<PostImportRowForm
|
||||||
|
row={row}
|
||||||
|
controls={controls}
|
||||||
|
onSave={vi.fn ()}/>)
|
||||||
|
await waitFor (() => expect (actions.length).toBe (2))
|
||||||
|
|
||||||
|
await act (async () => {
|
||||||
|
await actions.find (_1 => _1.label === '変更をリセット')?.onSelect ()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('marks edited fields and areas invalid from field errors', () => {
|
||||||
|
const row = buildPostImportRow ({
|
||||||
|
validationErrors: { url: ['URL error'], tags: ['tag error'] },
|
||||||
|
importErrors: { duration: ['duration error'] },
|
||||||
|
fieldWarnings: { title: ['title warning'] } })
|
||||||
|
|
||||||
|
render (
|
||||||
|
<PostImportRowForm
|
||||||
|
row={row}
|
||||||
|
controls={{ close: vi.fn (), confirm: vi.fn (), setActions: vi.fn () }}
|
||||||
|
onSave={vi.fn ()}/>)
|
||||||
|
|
||||||
|
expect (screen.getByText ('URL error')).toBeInTheDocument ()
|
||||||
|
expect (screen.getByText ('tag error')).toBeInTheDocument ()
|
||||||
|
expect (screen.getByText ('duration error')).toBeInTheDocument ()
|
||||||
|
expect (screen.getByText ('title warning')).toBeInTheDocument ()
|
||||||
|
expect (screen.getAllByRole ('textbox').filter (
|
||||||
|
_1 => _1.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||||
|
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||||
|
|
||||||
|
describe ('displayPostImportStatus', () => {
|
||||||
|
it ('shows only ready, warning, and skipped states', () => {
|
||||||
|
expect (displayPostImportStatus (buildPostImportRow ())).toBe ('ready')
|
||||||
|
expect (displayPostImportStatus (buildPostImportRow ({
|
||||||
|
status: 'warning',
|
||||||
|
fieldWarnings: { title: ['warning'] } }))).toBe ('warning')
|
||||||
|
expect (displayPostImportStatus (buildPostImportRow ({
|
||||||
|
skipReason: 'existing',
|
||||||
|
existingPostId: 2 }))).toBe ('skipped')
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('does not expose validation, failure, or created states as badges', () => {
|
||||||
|
expect (displayPostImportStatus (buildPostImportRow ({
|
||||||
|
status: 'error',
|
||||||
|
validationErrors: { title: ['invalid'] } }))).toBeNull ()
|
||||||
|
expect (displayPostImportStatus (buildPostImportRow ({
|
||||||
|
importStatus: 'failed' }))).toBeNull ()
|
||||||
|
expect (displayPostImportStatus (buildPostImportRow ({
|
||||||
|
importStatus: 'created',
|
||||||
|
createdPostId: 3 }))).toBeNull ()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { creatableImportRows,
|
||||||
|
initialisePreviewRows,
|
||||||
|
mergeImportResults,
|
||||||
|
mergeValidatedImportRows,
|
||||||
|
processableImportRows,
|
||||||
|
resultSummaryCounts,
|
||||||
|
retryImportRow,
|
||||||
|
reviewSummaryCounts } from '@/lib/postImportSession'
|
||||||
|
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||||
|
|
||||||
|
describe ('post import row state', () => {
|
||||||
|
it ('separates processable existing rows from creatable rows', () => {
|
||||||
|
const ready = buildPostImportRow ({ sourceRow: 1 })
|
||||||
|
const existing = buildPostImportRow ({
|
||||||
|
sourceRow: 2,
|
||||||
|
skipReason: 'existing',
|
||||||
|
existingPostId: 20 })
|
||||||
|
const invalid = buildPostImportRow ({
|
||||||
|
sourceRow: 3,
|
||||||
|
status: 'error',
|
||||||
|
validationErrors: { url: ['invalid'] } })
|
||||||
|
const created = buildPostImportRow ({
|
||||||
|
sourceRow: 4,
|
||||||
|
importStatus: 'created',
|
||||||
|
createdPostId: 40 })
|
||||||
|
const rows = [ready, existing, invalid, created]
|
||||||
|
|
||||||
|
expect (processableImportRows (rows)).toEqual ([ready, existing])
|
||||||
|
expect (creatableImportRows (rows)).toEqual ([ready])
|
||||||
|
expect (reviewSummaryCounts (rows)).toEqual ({
|
||||||
|
total: 4,
|
||||||
|
submittable: 1,
|
||||||
|
skipPlanned: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('preserves terminal rows while merging validation results', () => {
|
||||||
|
const created = buildPostImportRow ({
|
||||||
|
sourceRow: 1,
|
||||||
|
importStatus: 'created',
|
||||||
|
createdPostId: 10,
|
||||||
|
attributes: { title: 'created title' } })
|
||||||
|
const pending = buildPostImportRow ({
|
||||||
|
sourceRow: 2,
|
||||||
|
fieldWarnings: { title: ['old warning'] },
|
||||||
|
provenance: { title: 'manual' },
|
||||||
|
attributes: { title: 'manual title' } })
|
||||||
|
const validated = [
|
||||||
|
buildPostImportRow ({ sourceRow: 1, attributes: { title: 'changed' } }),
|
||||||
|
buildPostImportRow ({
|
||||||
|
sourceRow: 2,
|
||||||
|
fieldWarnings: { title: ['fetch warning'], tags: ['tag warning'] },
|
||||||
|
provenance: { title: 'manual' },
|
||||||
|
attributes: { title: 'manual title' } })]
|
||||||
|
|
||||||
|
const result = mergeValidatedImportRows ([created, pending], validated)
|
||||||
|
|
||||||
|
expect (result[0]).toBe (created)
|
||||||
|
expect (result[1]?.fieldWarnings).toEqual ({ tags: ['tag warning'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('updates the reset snapshot only after metadata URL changes', () => {
|
||||||
|
const current = buildPostImportRow ({
|
||||||
|
attributes: { title: 'manual title' },
|
||||||
|
metadataUrl: 'https://example.com/old' })
|
||||||
|
const validated = buildPostImportRow ({
|
||||||
|
attributes: { title: 'new metadata title' },
|
||||||
|
metadataUrl: 'https://example.com/new',
|
||||||
|
fieldWarnings: { title: ['warning'] },
|
||||||
|
baseWarnings: ['base warning'] })
|
||||||
|
|
||||||
|
const result = mergeValidatedImportRows ([current], [validated])[0]
|
||||||
|
|
||||||
|
expect (result?.resetSnapshot).toMatchObject ({
|
||||||
|
attributes: { title: 'new metadata title' },
|
||||||
|
fieldWarnings: { title: ['warning'] },
|
||||||
|
baseWarnings: ['base warning'],
|
||||||
|
metadataUrl: 'https://example.com/new' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('merges result states and clears incompatible post identifiers', () => {
|
||||||
|
const created = mergeImportResults ([buildPostImportRow ({
|
||||||
|
skipReason: 'existing',
|
||||||
|
existingPostId: 2 })], [{
|
||||||
|
sourceRow: 1,
|
||||||
|
status: 'created',
|
||||||
|
post: { id: 3 } }])[0]
|
||||||
|
const skipped = mergeImportResults ([buildPostImportRow ({
|
||||||
|
createdPostId: 3,
|
||||||
|
importStatus: 'created' })], [{
|
||||||
|
sourceRow: 1,
|
||||||
|
status: 'skipped',
|
||||||
|
existingPostId: 4 }])[0]
|
||||||
|
const failed = mergeImportResults ([buildPostImportRow ({
|
||||||
|
skipReason: 'existing',
|
||||||
|
existingPostId: 4 })], [{
|
||||||
|
sourceRow: 1,
|
||||||
|
status: 'failed',
|
||||||
|
errors: { base: ['failure'] } }])[0]
|
||||||
|
|
||||||
|
expect (created).toMatchObject ({
|
||||||
|
importStatus: 'created',
|
||||||
|
createdPostId: 3,
|
||||||
|
existingPostId: undefined,
|
||||||
|
skipReason: undefined })
|
||||||
|
expect (skipped).toMatchObject ({
|
||||||
|
importStatus: 'skipped',
|
||||||
|
existingPostId: 4,
|
||||||
|
createdPostId: undefined,
|
||||||
|
skipReason: 'existing' })
|
||||||
|
expect (failed).toMatchObject ({
|
||||||
|
importStatus: 'failed',
|
||||||
|
createdPostId: undefined,
|
||||||
|
existingPostId: undefined,
|
||||||
|
skipReason: undefined,
|
||||||
|
importErrors: { base: ['failure'] } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('retries only the selected failed row and counts results exclusively', () => {
|
||||||
|
const rows = [
|
||||||
|
buildPostImportRow ({ sourceRow: 1, importStatus: 'created', createdPostId: 1 }),
|
||||||
|
buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped',
|
||||||
|
existingPostId: 2, skipReason: 'existing' }),
|
||||||
|
buildPostImportRow ({ sourceRow: 3, importStatus: 'failed',
|
||||||
|
importErrors: { base: ['failed'] } })]
|
||||||
|
|
||||||
|
expect (resultSummaryCounts (rows)).toEqual ({ created: 1, skipped: 1, failed: 1 })
|
||||||
|
expect (retryImportRow (rows, 3)[2]).toMatchObject ({
|
||||||
|
importStatus: 'pending',
|
||||||
|
importErrors: undefined })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('copies reset snapshot values instead of sharing mutable records', () => {
|
||||||
|
const row = buildPostImportRow ({ fieldWarnings: { title: ['warning'] } })
|
||||||
|
const initialised = initialisePreviewRows ([row])[0]
|
||||||
|
expect (initialised).toBeDefined ()
|
||||||
|
if (initialised == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
initialised.attributes.title = 'changed'
|
||||||
|
initialised.fieldWarnings.title?.push ('another')
|
||||||
|
|
||||||
|
expect (initialised.resetSnapshot.attributes.title).toBe ('')
|
||||||
|
expect (initialised.resetSnapshot.fieldWarnings.title).toEqual (['warning'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { countImportSourceLines, validateImportSource } from '@/lib/postImportSession'
|
||||||
|
|
||||||
|
describe ('post import source validation', () => {
|
||||||
|
it ('counts trimmed non-empty CRLF and LF rows', () => {
|
||||||
|
expect (countImportSourceLines (' one \r\n\r\n two\n')).toBe (2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('reports invalid protocols and malformed URLs with original line numbers', () => {
|
||||||
|
const issues = validateImportSource (
|
||||||
|
'\nftp://example.com/file\nhttps://exa mple.com/path')
|
||||||
|
|
||||||
|
expect (issues).toEqual ([
|
||||||
|
{
|
||||||
|
sourceRow: 2,
|
||||||
|
message: 'HTTP または HTTPS の URL ではありません.',
|
||||||
|
url: 'ftp://example.com/file' },
|
||||||
|
{
|
||||||
|
sourceRow: 3,
|
||||||
|
message: 'URL の形式が不正です.',
|
||||||
|
url: 'https://exa mple.com/path' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('detects duplicates after frontend URL normalisation', () => {
|
||||||
|
const issues = validateImportSource (
|
||||||
|
'https://EXAMPLE.com/path/\nhttps://example.com/path')
|
||||||
|
|
||||||
|
expect (issues).toEqual ([{
|
||||||
|
sourceRow: 2,
|
||||||
|
message: '1 行目と同じ URL です.',
|
||||||
|
url: 'https://example.com/path' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('rejects oversized URLs and rows beyond the maximum count', () => {
|
||||||
|
const oversized = `https://example.com/${ 'a'.repeat (20 * 1024) }`
|
||||||
|
const tooMany = Array.from (
|
||||||
|
{ length: 101 },
|
||||||
|
(_, index) => `https://example.com/${ index }`).join ('\n')
|
||||||
|
|
||||||
|
expect (validateImportSource (oversized)[0]).toMatchObject ({
|
||||||
|
sourceRow: 1,
|
||||||
|
message: 'URL が長すぎます.' })
|
||||||
|
expect (validateImportSource (tooMany).at (-1)).toEqual ({
|
||||||
|
sourceRow: 101,
|
||||||
|
message: '取込件数は 100 件までです.',
|
||||||
|
url: 'https://example.com/100' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { cleanupExpiredPostImportSessions,
|
||||||
|
clearPostImportSourceDraft,
|
||||||
|
loadPostImportSession,
|
||||||
|
loadPostImportSourceDraft,
|
||||||
|
savePostImportSession,
|
||||||
|
savePostImportSourceDraft } from '@/lib/postImportSession'
|
||||||
|
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||||
|
|
||||||
|
describe ('post import storage', () => {
|
||||||
|
beforeEach (() => {
|
||||||
|
sessionStorage.clear ()
|
||||||
|
vi.useRealTimers ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('round-trips a valid session and source draft', () => {
|
||||||
|
const row = buildPostImportRow ({
|
||||||
|
importStatus: 'skipped',
|
||||||
|
skipReason: 'existing',
|
||||||
|
existingPostId: 10 })
|
||||||
|
|
||||||
|
expect (savePostImportSession ('session', {
|
||||||
|
source: row.url,
|
||||||
|
rows: [row],
|
||||||
|
repairMode: 'all' })).toBe (true)
|
||||||
|
expect (loadPostImportSession ('session')).toMatchObject ({
|
||||||
|
version: 2,
|
||||||
|
source: row.url,
|
||||||
|
rows: [{
|
||||||
|
importStatus: 'skipped',
|
||||||
|
skipReason: 'existing',
|
||||||
|
existingPostId: 10 }] })
|
||||||
|
|
||||||
|
expect (savePostImportSourceDraft ('https://example.com')).toBe (true)
|
||||||
|
expect (loadPostImportSourceDraft ()).toEqual ({ source: 'https://example.com' })
|
||||||
|
clearPostImportSourceDraft ()
|
||||||
|
expect (loadPostImportSourceDraft ()).toEqual ({ source: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('rejects inconsistent post IDs and terminal statuses', () => {
|
||||||
|
const session = {
|
||||||
|
version: 2,
|
||||||
|
savedAt: new Date ().toISOString (),
|
||||||
|
source: '',
|
||||||
|
repairMode: 'all',
|
||||||
|
rows: [buildPostImportRow ()] }
|
||||||
|
const invalidRows = [
|
||||||
|
{ ...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 }]
|
||||||
|
|
||||||
|
for (const [index, row] of invalidRows.entries ())
|
||||||
|
{
|
||||||
|
sessionStorage.setItem (`post-import-session:invalid-${ index }`, JSON.stringify ({
|
||||||
|
...session,
|
||||||
|
rows: [row] }))
|
||||||
|
expect (loadPostImportSession (`invalid-${ index }`)).toBeNull ()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('rejects invalid attribute, provenance, tag-source, and snapshot data', () => {
|
||||||
|
const row = buildPostImportRow ()
|
||||||
|
const invalidRows = [
|
||||||
|
{ ...row, attributes: { title: [] } },
|
||||||
|
{ ...row, attributes: { unknown: 'value' } },
|
||||||
|
{ ...row, provenance: { title: 'mapped' } },
|
||||||
|
{ ...row, tagSources: { mapped: 'tag' } },
|
||||||
|
{ ...row, resetSnapshot: { ...row.resetSnapshot,
|
||||||
|
fieldWarnings: { title: 'warning' } } }]
|
||||||
|
|
||||||
|
invalidRows.forEach ((invalidRow, index) => {
|
||||||
|
sessionStorage.setItem (`post-import-session:shape-${ index }`, JSON.stringify ({
|
||||||
|
version: 2,
|
||||||
|
savedAt: new Date ().toISOString (),
|
||||||
|
source: '',
|
||||||
|
rows: [invalidRow],
|
||||||
|
repairMode: 'all' }))
|
||||||
|
expect (loadPostImportSession (`shape-${ index }`)).toBeNull ()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('removes expired and malformed sessions without touching current sessions', () => {
|
||||||
|
const current = {
|
||||||
|
version: 2,
|
||||||
|
savedAt: new Date ().toISOString (),
|
||||||
|
source: '',
|
||||||
|
rows: [buildPostImportRow ()],
|
||||||
|
repairMode: 'all' }
|
||||||
|
const expired = {
|
||||||
|
...current,
|
||||||
|
savedAt: new Date (Date.now () - 25 * 60 * 60 * 1000).toISOString () }
|
||||||
|
sessionStorage.setItem ('post-import-session:current', JSON.stringify (current))
|
||||||
|
sessionStorage.setItem ('post-import-session:expired', JSON.stringify (expired))
|
||||||
|
sessionStorage.setItem ('post-import-session:malformed', '{')
|
||||||
|
|
||||||
|
cleanupExpiredPostImportSessions ()
|
||||||
|
|
||||||
|
expect (sessionStorage.getItem ('post-import-session:current')).not.toBeNull ()
|
||||||
|
expect (sessionStorage.getItem ('post-import-session:expired')).toBeNull ()
|
||||||
|
expect (sessionStorage.getItem ('post-import-session:malformed')).toBeNull ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('reports storage access failures without throwing', () => {
|
||||||
|
const onError = vi.fn ()
|
||||||
|
vi.spyOn (Storage.prototype, 'setItem').mockImplementation (() => {
|
||||||
|
throw new DOMException ('quota')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect (savePostImportSourceDraft ('source', onError)).toBe (false)
|
||||||
|
expect (onError).toHaveBeenCalledWith ('ブラウザへ保存できませんでした.')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { fireEvent, screen } from '@testing-library/react'
|
||||||
|
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { savePostImportSession } from '@/lib/postImportSession'
|
||||||
|
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
|
||||||
|
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 () }))
|
||||||
|
|
||||||
|
vi.mock ('@/lib/api', () => api)
|
||||||
|
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||||
|
|
||||||
|
const ReviewLocation = () => {
|
||||||
|
const location = useLocation ()
|
||||||
|
return <div>{`review route ${ location.search }`}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPage = () => renderWithProviders (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/posts/import/:sessionId/result"
|
||||||
|
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||||
|
<Route path="/posts/import/:sessionId/review" element={<ReviewLocation/>}/>
|
||||||
|
</Routes>,
|
||||||
|
{ route: '/posts/import/session/result' })
|
||||||
|
|
||||||
|
describe ('PostImportResultPage', () => {
|
||||||
|
beforeEach (() => {
|
||||||
|
sessionStorage.clear ()
|
||||||
|
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' })
|
||||||
|
|
||||||
|
renderPage ()
|
||||||
|
|
||||||
|
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 ('returns a retry validation error to the review dialogue route', 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 ('review route ?edit=1')).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: ['タイトルを確認してください.'] } })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { 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 () }))
|
||||||
|
|
||||||
|
vi.mock ('@/lib/api', () => api)
|
||||||
|
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||||
|
|
||||||
|
const renderPage = () => renderWithProviders (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/posts/import/:sessionId/review"
|
||||||
|
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
||||||
|
<Route path="/posts/import/:sessionId/result" element={<div>result route</div>}/>
|
||||||
|
</Routes>,
|
||||||
|
{ route: '/posts/import/session/review' })
|
||||||
|
|
||||||
|
describe ('PostImportReviewPage', () => {
|
||||||
|
beforeEach (() => {
|
||||||
|
sessionStorage.clear ()
|
||||||
|
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' })
|
||||||
|
api.apiPost
|
||||||
|
.mockResolvedValueOnce ({ rows: [row] })
|
||||||
|
.mockResolvedValueOnce ({
|
||||||
|
created: 0,
|
||||||
|
skipped: 1,
|
||||||
|
failed: 0,
|
||||||
|
rows: [{ sourceRow: 1, status: 'skipped', existingPostId: 10 }] })
|
||||||
|
|
||||||
|
renderPage ()
|
||||||
|
fireEvent.click (await screen.findByRole ('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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
|
||||||
|
import { buildUser } from '@/test/factories'
|
||||||
|
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||||
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
|
const api = vi.hoisted (() => ({
|
||||||
|
apiPost: vi.fn (),
|
||||||
|
isApiError: vi.fn () }))
|
||||||
|
|
||||||
|
const router = vi.hoisted (() => ({ navigate: vi.fn () }))
|
||||||
|
|
||||||
|
const toastApi = vi.hoisted (() => ({ toast: vi.fn () }))
|
||||||
|
|
||||||
|
vi.mock ('@/lib/api', () => api)
|
||||||
|
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||||
|
vi.mock ('react-router-dom', async importOriginal => ({
|
||||||
|
...await importOriginal<typeof import('react-router-dom')> (),
|
||||||
|
useNavigate: () => router.navigate }))
|
||||||
|
|
||||||
|
describe ('PostImportSourcePage', () => {
|
||||||
|
beforeEach (() => {
|
||||||
|
sessionStorage.clear ()
|
||||||
|
vi.clearAllMocks ()
|
||||||
|
api.isApiError.mockReturnValue (false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('shows no empty error initially and validates only after Next is pressed', () => {
|
||||||
|
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||||
|
|
||||||
|
expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument ()
|
||||||
|
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||||
|
expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument ()
|
||||||
|
expect (api.apiPost).not.toHaveBeenCalled ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('shows frontend URL issues with line numbers without requesting preview', () => {
|
||||||
|
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||||
|
const input = screen.getByRole ('textbox', { name: '' })
|
||||||
|
|
||||||
|
fireEvent.change (input, {
|
||||||
|
target: { value: '\nftp://example.com/file\nhttps://example.com/valid' } })
|
||||||
|
expect (screen.queryByText (/2 行目/)).not.toBeInTheDocument ()
|
||||||
|
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||||
|
|
||||||
|
expect (screen.getByText (/2 行目: HTTP または HTTPS/)).toBeInTheDocument ()
|
||||||
|
expect (screen.getByText ('ftp://example.com/file')).toBeInTheDocument ()
|
||||||
|
expect (input).toHaveAttribute ('aria-invalid', 'true')
|
||||||
|
expect (input.getAttribute ('aria-describedby')).toContain ('post-import-source-issues')
|
||||||
|
expect (api.apiPost).not.toHaveBeenCalled ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('shows backend URL errors against original input without a session', async () => {
|
||||||
|
api.apiPost.mockResolvedValue ({
|
||||||
|
rows: [buildPostImportRow ({
|
||||||
|
sourceRow: 2,
|
||||||
|
url: 'https://example.com/canonical',
|
||||||
|
status: 'error',
|
||||||
|
validationErrors: { url: ['URL が重複しています.'] } })] })
|
||||||
|
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||||
|
|
||||||
|
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
|
||||||
|
target: { value: '\nhttps://example.com/original' } })
|
||||||
|
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||||
|
|
||||||
|
expect (await screen.findByText ('2 行目: URL が重複しています.')).toBeInTheDocument ()
|
||||||
|
expect (screen.getAllByText ('https://example.com/original')).toHaveLength (2)
|
||||||
|
expect (router.navigate).not.toHaveBeenCalled ()
|
||||||
|
expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
|
||||||
|
sessionStorage.key (index))).not.toContainEqual(
|
||||||
|
expect.stringMatching (/^post-import-session:/))
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('stores a successful preview before navigating to the review route', async () => {
|
||||||
|
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ()] })
|
||||||
|
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||||
|
|
||||||
|
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
|
||||||
|
target: { value: 'https://example.com/post' } })
|
||||||
|
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (router.navigate).toHaveBeenCalledWith (
|
||||||
|
expect.stringMatching (/^\/posts\/import\/[^/]+\/review$/))
|
||||||
|
})
|
||||||
|
const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) =>
|
||||||
|
sessionStorage.key (index)).filter (_1 => _1?.startsWith ('post-import-session:'))
|
||||||
|
expect (sessionKeys).toHaveLength (1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { PostImportRow } from '@/lib/postImportSession'
|
||||||
|
|
||||||
|
export const buildPostImportRow = (
|
||||||
|
overrides: Partial<PostImportRow> = {},
|
||||||
|
): PostImportRow => {
|
||||||
|
const attributes = {
|
||||||
|
title: '',
|
||||||
|
thumbnailBase: '',
|
||||||
|
originalCreatedFrom: '',
|
||||||
|
originalCreatedBefore: '',
|
||||||
|
duration: '',
|
||||||
|
tags: '',
|
||||||
|
parentPostIds: '',
|
||||||
|
...overrides.attributes }
|
||||||
|
const provenance = {
|
||||||
|
url: 'manual' as const,
|
||||||
|
title: 'automatic' as const,
|
||||||
|
thumbnailBase: 'automatic' as const,
|
||||||
|
originalCreatedFrom: 'automatic' as const,
|
||||||
|
originalCreatedBefore: 'automatic' as const,
|
||||||
|
duration: 'automatic' as const,
|
||||||
|
tags: 'automatic' as const,
|
||||||
|
parentPostIds: 'automatic' as const,
|
||||||
|
...overrides.provenance }
|
||||||
|
const fieldWarnings = { ...overrides.fieldWarnings }
|
||||||
|
const baseWarnings = [...(overrides.baseWarnings ?? [])]
|
||||||
|
const tagSources = {
|
||||||
|
automatic: overrides.tagSources?.automatic ?? '',
|
||||||
|
manual: overrides.tagSources?.manual ?? '' }
|
||||||
|
const url = overrides.url ?? 'https://example.com/post'
|
||||||
|
|
||||||
|
return {
|
||||||
|
...overrides,
|
||||||
|
sourceRow: overrides.sourceRow ?? 1,
|
||||||
|
url,
|
||||||
|
attributes,
|
||||||
|
fieldWarnings,
|
||||||
|
baseWarnings,
|
||||||
|
validationErrors: overrides.validationErrors ?? {},
|
||||||
|
provenance,
|
||||||
|
tagSources,
|
||||||
|
status: overrides.status ?? 'ready',
|
||||||
|
resetSnapshot: overrides.resetSnapshot ?? {
|
||||||
|
url,
|
||||||
|
attributes: { ...attributes },
|
||||||
|
provenance: { ...provenance },
|
||||||
|
tagSources: { ...tagSources },
|
||||||
|
fieldWarnings: Object.fromEntries (
|
||||||
|
Object.entries (fieldWarnings).map (([key, values]) => [key, [...values]])),
|
||||||
|
baseWarnings: [...baseWarnings],
|
||||||
|
metadataUrl: overrides.metadataUrl } }
|
||||||
|
}
|
||||||
新しい課題から参照
ユーザをブロックする