diff --git a/backend/spec/requests/post_imports_spec.rb b/backend/spec/requests/post_imports_spec.rb
new file mode 100644
index 0000000..b3c6ae8
--- /dev/null
+++ b/backend/spec/requests/post_imports_spec.rb
@@ -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
diff --git a/backend/spec/services/post_import_previewer_spec.rb b/backend/spec/services/post_import_previewer_spec.rb
new file mode 100644
index 0000000..fc6e16b
--- /dev/null
+++ b/backend/spec/services/post_import_previewer_spec.rb
@@ -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
diff --git a/backend/spec/services/post_import_row_normaliser_spec.rb b/backend/spec/services/post_import_row_normaliser_spec.rb
new file mode 100644
index 0000000..ec1a41c
--- /dev/null
+++ b/backend/spec/services/post_import_row_normaliser_spec.rb
@@ -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
diff --git a/backend/spec/services/post_import_runner_spec.rb b/backend/spec/services/post_import_runner_spec.rb
new file mode 100644
index 0000000..235dabf
--- /dev/null
+++ b/backend/spec/services/post_import_runner_spec.rb
@@ -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
diff --git a/backend/spec/services/post_import_url_list_parser_spec.rb b/backend/spec/services/post_import_url_list_parser_spec.rb
new file mode 100644
index 0000000..0704abc
--- /dev/null
+++ b/backend/spec/services/post_import_url_list_parser_spec.rb
@@ -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
diff --git a/backend/spec/services/post_metadata_fetcher_spec.rb b/backend/spec/services/post_metadata_fetcher_spec.rb
new file mode 100644
index 0000000..38e5d53
--- /dev/null
+++ b/backend/spec/services/post_metadata_fetcher_spec.rb
@@ -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
+ 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
diff --git a/backend/spec/services/post_url_normaliser_spec.rb b/backend/spec/services/post_url_normaliser_spec.rb
new file mode 100644
index 0000000..dafcdb2
--- /dev/null
+++ b/backend/spec/services/post_url_normaliser_spec.rb
@@ -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
diff --git a/frontend/src/components/TopNav.test.tsx b/frontend/src/components/TopNav.test.tsx
new file mode 100644
index 0000000..4d41dcd
--- /dev/null
+++ b/frontend/src/components/TopNav.test.tsx
@@ -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)
+ })
+})
diff --git a/frontend/src/components/dialogues/DialogueProvider.test.tsx b/frontend/src/components/dialogues/DialogueProvider.test.tsx
new file mode 100644
index 0000000..d2bc4f8
--- /dev/null
+++ b/frontend/src/components/dialogues/DialogueProvider.test.tsx
@@ -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 },
+) => {
+ useEffect (() => {
+ controls.setActions ([{
+ label: '左操作',
+ placement: 'start',
+ onSelect }, {
+ label: '保存',
+ onSelect: () => true }])
+ }, [controls, onSelect])
+
+ return 長いフォーム本文
+}
+
+const NestedConfirmFormBody = ({ controls }: { controls: DialogueFormControls }) => {
+ const reset = useCallback (async () => {
+ await controls.confirm ({
+ title: '変更をリセットしますか?',
+ confirmText: 'リセット' })
+ return false
+ }, [controls])
+
+ return
+}
+
+describe ('DialogueProvider', () => {
+ it ('keeps ordinary dialogues in FIFO order', async () => {
+ const Launcher = () => {
+ const dialogue = useDialogue ()
+ return (
+ )
+ }
+
+ render ()
+ 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 (
+ )
+ }
+
+ render ()
+ 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 (
+ )
+ }
+
+ render ()
+ 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 ()
+ })
+})
diff --git a/frontend/src/components/posts/import/PostImportRowForm.test.tsx b/frontend/src/components/posts/import/PostImportRowForm.test.tsx
new file mode 100644
index 0000000..26b3c79
--- /dev/null
+++ b/frontend/src/components/posts/import/PostImportRowForm.test.tsx
@@ -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 ()
+ 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 (
+ )
+ 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 (
+ )
+
+ 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)
+ })
+})
diff --git a/frontend/src/components/posts/import/postImportRowStatus.test.ts b/frontend/src/components/posts/import/postImportRowStatus.test.ts
new file mode 100644
index 0000000..30e78fa
--- /dev/null
+++ b/frontend/src/components/posts/import/postImportRowStatus.test.ts
@@ -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 ()
+ })
+})
diff --git a/frontend/src/lib/postImportRows.test.ts b/frontend/src/lib/postImportRows.test.ts
new file mode 100644
index 0000000..b1aeb01
--- /dev/null
+++ b/frontend/src/lib/postImportRows.test.ts
@@ -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'])
+ })
+})
diff --git a/frontend/src/lib/postImportSourceValidation.test.ts b/frontend/src/lib/postImportSourceValidation.test.ts
new file mode 100644
index 0000000..2f565f5
--- /dev/null
+++ b/frontend/src/lib/postImportSourceValidation.test.ts
@@ -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' })
+ })
+})
diff --git a/frontend/src/lib/postImportStorage.test.ts b/frontend/src/lib/postImportStorage.test.ts
new file mode 100644
index 0000000..9bed899
--- /dev/null
+++ b/frontend/src/lib/postImportStorage.test.ts
@@ -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 ('ブラウザへ保存できませんでした.')
+ })
+})
diff --git a/frontend/src/pages/posts/PostImportResultPage.test.tsx b/frontend/src/pages/posts/PostImportResultPage.test.tsx
new file mode 100644
index 0000000..4a07415
--- /dev/null
+++ b/frontend/src/pages/posts/PostImportResultPage.test.tsx
@@ -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 {`review route ${ location.search }`}
+}
+
+const renderPage = () => renderWithProviders (
+
+ }/>
+ }/>
+ ,
+ { 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: ['タイトルを確認してください.'] } })
+ })
+})
diff --git a/frontend/src/pages/posts/PostImportReviewPage.test.tsx b/frontend/src/pages/posts/PostImportReviewPage.test.tsx
new file mode 100644
index 0000000..3e6d409
--- /dev/null
+++ b/frontend/src/pages/posts/PostImportReviewPage.test.tsx
@@ -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 (
+
+ }/>
+ result route}/>
+ ,
+ { 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' })
+ })
+ })
+})
diff --git a/frontend/src/pages/posts/PostImportSourcePage.test.tsx b/frontend/src/pages/posts/PostImportSourcePage.test.tsx
new file mode 100644
index 0000000..a8d5af5
--- /dev/null
+++ b/frontend/src/pages/posts/PostImportSourcePage.test.tsx
@@ -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 (),
+ 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 ()
+
+ 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 ()
+ 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 ()
+
+ 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 ()
+
+ 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)
+ })
+})
diff --git a/frontend/src/test/postImportFactories.ts b/frontend/src/test/postImportFactories.ts
new file mode 100644
index 0000000..cc6a10a
--- /dev/null
+++ b/frontend/src/test/postImportFactories.ts
@@ -0,0 +1,52 @@
+import type { PostImportRow } from '@/lib/postImportSession'
+
+export const buildPostImportRow = (
+ overrides: Partial = {},
+): 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 } }
+}