diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb
index f76e83a..fb3c61a 100644
--- a/backend/app/controllers/posts_controller.rb
+++ b/backend/app/controllers/posts_controller.rb
@@ -732,6 +732,7 @@ class PostsController < ApplicationController
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
tag_names:, video_ms_param:, duration_param:, parent_post_ids:
+ validate_original_created_values!(original_created_from, original_created_before)
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
with_sections: true) =>
{ tags:, sections: }
@@ -769,6 +770,23 @@ class PostsController < ApplicationController
value.to_s
end
+ def validate_original_created_values! original_created_from, original_created_before
+ candidate = Post.new(
+ url: 'https://example.invalid/original-created-validation',
+ original_created_from:,
+ original_created_before:)
+ candidate.valid?
+ fields = [:original_created_from, :original_created_before, :original_created_at]
+ relevant_errors = candidate.errors.select { fields.include?(_1.attribute) }
+ return if relevant_errors.empty?
+
+ invalid_post = Post.new
+ relevant_errors.each { |error|
+ invalid_post.errors.add(error.attribute, error.message)
+ }
+ raise ActiveRecord::RecordInvalid, invalid_post
+ end
+
def section_literal section
"[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
end
diff --git a/backend/app/services/post_create_plan.rb b/backend/app/services/post_create_plan.rb
index 5001178..96790fe 100644
--- a/backend/app/services/post_create_plan.rb
+++ b/backend/app/services/post_create_plan.rb
@@ -201,7 +201,7 @@ class PostCreatePlan
def serialised_tags direct_tag_specs, tag_sections
direct_tag_specs.map { |spec|
- "#{ spec[:name] }#{ tag_sections[spec[:name]].to_a.map { Post.section_literal(_1) }.join }"
+ "#{ spec[:name] }#{ tag_sections[spec[:name]].to_a.map { section_literal(_1) }.join }"
}.sort.join(' ')
end
@@ -210,10 +210,15 @@ class PostCreatePlan
{
name: spec[:name],
category: spec[:category].to_s,
- section_literals: tag_sections[spec[:name]].to_a.map { Post.section_literal(_1) } }
+ section_literals: tag_sections[spec[:name]].to_a.map { section_literal(_1) } }
}.sort_by { _1[:name] }
end
+ def section_literal range
+ begin_ms, end_ms = range
+ "[#{ Post.ms_to_time(begin_ms) }-#{ end_ms ? Post.ms_to_time(end_ms) : '' }]"
+ end
+
def normalise_video_ms snapshot_tag_specs
return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME }
diff --git a/backend/spec/models/post_spec.rb b/backend/spec/models/post_spec.rb
index 7c8977b..c097fdf 100644
--- a/backend/spec/models/post_spec.rb
+++ b/backend/spec/models/post_spec.rb
@@ -110,7 +110,7 @@ RSpec.describe Post, type: :model do
end
describe '.resized_thumbnail_attachment' do
- it 'centre-crops a wide image to 180x180 without distorting it' do
+ it 'fits a wide image within 180x180 without distorting it' do
blob = image_blob(
width: 360,
height: 180,
@@ -123,13 +123,13 @@ RSpec.describe Post, type: :model do
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
image = read_image(resized)
- expect(image.dimensions).to eq([180, 180])
- expect_green(colour_at(image, 0, 90))
- expect_green(colour_at(image, 90, 90))
- expect_green(colour_at(image, 179, 90))
+ expect(image.dimensions).to eq([180, 90])
+ expect_red(colour_at(image, 0, 45))
+ expect_green(colour_at(image, 90, 45))
+ expect_red(colour_at(image, 179, 45))
end
- it 'centre-crops a tall image to 180x180 without distorting it' do
+ it 'fits a tall image within 180x180 without distorting it' do
blob = image_blob(
width: 180,
height: 360,
@@ -142,10 +142,10 @@ RSpec.describe Post, type: :model do
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
image = read_image(resized)
- expect(image.dimensions).to eq([180, 180])
- expect_green(colour_at(image, 90, 0))
- expect_green(colour_at(image, 90, 90))
- expect_green(colour_at(image, 90, 179))
+ expect(image.dimensions).to eq([90, 180])
+ expect_red(colour_at(image, 45, 0))
+ expect_green(colour_at(image, 45, 90))
+ expect_red(colour_at(image, 45, 179))
end
it 'keeps a square image square without distortion' do
@@ -192,7 +192,7 @@ RSpec.describe Post, type: :model do
expect(post.thumbnail).to be_attached
image = read_image(post.thumbnail)
- expect(image.dimensions).to eq([180, 180])
+ expect(image.dimensions).to eq([180, 135])
end
it 'does not attach anything when thumbnail conversion fails' do
diff --git a/backend/spec/requests/post_imports_spec.rb b/backend/spec/requests/post_imports_spec.rb
deleted file mode 100644
index 78be75f..0000000
--- a/backend/spec/requests/post_imports_spec.rb
+++ /dev/null
@@ -1,184 +0,0 @@
-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
-
- it 'returns original created datetime validation errors for minute precision' do
- sign_in_as(member)
-
- post '/posts/import/validate', params: {
- rows: [{
- sourceRow: 1,
- url: 'https://example.com/post',
- metadataUrl: 'https://example.com/post',
- attributes: {
- originalCreatedFrom: '2020-01-01T00:00:30Z',
- originalCreatedBefore: '2020-01-01T00:01Z' },
- provenance: {
- url: 'manual',
- originalCreatedFrom: 'manual',
- originalCreatedBefore: 'manual' },
- tagSources: { automatic: '', manual: '' }
- }],
- changed_row: -1
- }
-
- expect(response).to have_http_status(:ok)
- expect(json.fetch('rows').first.fetch('validation_errors')).to include(
- 'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.']
- )
- expect(json.fetch('rows').first.fetch('validation_errors'))
- .not_to have_key('original_created_at')
- end
- end
-
- describe 'POST /posts/import' do
- it 'keeps the duration string contract through preview, validate, and import' do
- sign_in_as(member)
- allow(PostMetadataFetcher).to receive(:fetch).and_return(
- title: 'fetched title',
- thumbnail_base: nil,
- duration: '2.5',
- tags: '動画'
- )
-
- post '/posts/import/preview', params: {
- source: 'https://example.com/video'
- }
- preview_row = json.fetch('rows').first
- expect(preview_row.dig('attributes', 'duration')).to eq('2.5')
-
- post '/posts/import/validate', params: {
- rows: [{
- sourceRow: preview_row.fetch('source_row'),
- url: preview_row.fetch('url'),
- attributes: preview_row.fetch('attributes'),
- provenance: preview_row.fetch('provenance'),
- tagSources: preview_row.fetch('tag_sources'),
- metadataUrl: preview_row.fetch('metadata_url')
- }],
- changed_row: -1
- }
- validated_row = json.fetch('rows').first
- expect(validated_row.dig('attributes', 'duration')).to eq('2.5')
-
- post '/posts/import', params: {
- rows: [{
- sourceRow: validated_row.fetch('source_row'),
- url: validated_row.fetch('url'),
- attributes: validated_row.fetch('attributes'),
- provenance: validated_row.fetch('provenance'),
- tagSources: validated_row.fetch('tag_sources'),
- metadataUrl: validated_row.fetch('metadata_url')
- }]
- }
-
- expect(response).to have_http_status(:ok)
- expect(Post.order(:id).last.video_ms).to eq(2_500)
- end
-
- 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/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb
index dfa3963..537a093 100644
--- a/backend/spec/requests/posts_spec.rb
+++ b/backend/spec/requests/posts_spec.rb
@@ -9,11 +9,11 @@ RSpec.describe 'Posts API', type: :request do
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
before do
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
- allow(Post).to receive(:resized_thumbnail_attachment).and_return(
- io: StringIO.new('dummy'),
- filename: 'resized_thumbnail.jpg',
- content_type: 'image/jpeg'
- )
+ allow(Post).to receive(:resized_thumbnail_attachment) do
+ { io: StringIO.new('dummy'),
+ filename: 'resized_thumbnail.jpg',
+ content_type: 'image/jpeg' }
+ end
end
def create_nico_tag!(name)
@@ -21,8 +21,7 @@ RSpec.describe 'Posts API', type: :request do
end
def dummy_upload
- # 中身は何でもいい(加工処理はスタブしてる)
- Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
+ real_thumbnail_upload
end
def real_thumbnail_upload
@@ -693,6 +692,73 @@ RSpec.describe 'Posts API', type: :request do
end
end
+ describe 'GET /posts/metadata' do
+ let(:member) { create(:user, :member) }
+
+ it 'returns compact existing post data without fetching external metadata' do
+ sign_in_as(member)
+ existing = create(
+ :post,
+ title: 'existing post',
+ url: 'https://example.com/existing')
+ existing.thumbnail.attach(
+ io: StringIO.new('thumbnail'),
+ filename: 'thumbnail.jpg',
+ content_type: 'image/jpeg')
+ expect(PostMetadataFetcher).not_to receive(:fetch)
+
+ get '/posts/metadata', params: { url: 'https://example.com/existing' }
+
+ expect(response).to have_http_status(:ok)
+ expect(json).to include(
+ 'url' => existing.url,
+ 'existing_post_id' => existing.id,
+ 'field_warnings' => { })
+ expect(json.fetch('existing_post')).to include(
+ 'id' => existing.id,
+ 'title' => existing.title,
+ 'url' => existing.url,
+ 'thumbnail_base' => existing.thumbnail_base)
+ expect(json.dig('existing_post', 'thumbnail'))
+ .to include('/rails/active_storage/blobs/proxy/')
+ end
+
+ it 'returns fetched metadata and structured display tags' do
+ sign_in_as(member)
+ allow(Preview::UrlSafety).to receive(:validate)
+ allow(PostMetadataFetcher).to receive(:fetch).and_return(
+ title: 'fetched title',
+ thumbnail_base: 'https://example.com/thumbnail.jpg',
+ tags: 'character:虹夏',
+ display_tags: [{ name: '虹夏', category: 'character' }],
+ original_created_from: nil,
+ original_created_before: nil,
+ duration: '1:00',
+ video_ms: 60_000)
+
+ get '/posts/metadata', params: { url: 'https://example.com/new' }
+
+ expect(response).to have_http_status(:ok)
+ expect(json).to include(
+ 'url' => 'https://example.com/new',
+ 'title' => 'fetched title',
+ 'duration' => '1:00',
+ 'video_ms' => 60_000,
+ 'field_warnings' => { })
+ expect(json.fetch('display_tags')).to eq(
+ [{ 'name' => '虹夏', 'category' => 'character' }])
+ end
+
+ it 'returns URL validation errors as 422' do
+ sign_in_as(member)
+
+ get '/posts/metadata', params: { url: 'file:///etc/passwd' }
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(json.fetch('errors')).to have_key('url')
+ end
+ end
+
describe 'POST /posts' do
let(:member) { create(:user, :member) }
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
@@ -712,6 +778,46 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:forbidden)
end
+ it 'dry-runs without persisting posts or new tags' do
+ sign_in_as(member)
+ counts = [Post.count, Tag.count, TagName.count]
+
+ post '/posts?dry=1', params: post_write_params(
+ title: 'dry-run post',
+ url: 'https://example.com/dry-run',
+ tags: 'character:new_dry_run_tag')
+
+ expect(response).to have_http_status(:ok)
+ expect(json).to include(
+ 'url' => 'https://example.com/dry-run',
+ 'tags' => 'new_dry_run_tag',
+ 'existing_post_id' => nil)
+ expect(json.fetch('display_tags')).to eq(
+ [{ 'name' => 'new_dry_run_tag',
+ 'category' => 'character',
+ 'section_literals' => [] }])
+ expect([Post.count, Tag.count, TagName.count]).to eq(counts)
+ end
+
+ it 'dry-runs an existing URL without validating its upload' do
+ sign_in_as(member)
+ existing = create(:post, url: 'https://example.com/dry-existing')
+ invalid_upload = Rack::Test::UploadedFile.new(
+ StringIO.new(''),
+ 'image/png',
+ original_filename: 'thumbnail.png')
+
+ post '/posts?dry=1', params: post_write_params(
+ title: '',
+ url: existing.url,
+ tags: '',
+ thumbnail: invalid_upload)
+
+ expect(response).to have_http_status(:ok)
+ expect(json).to include('existing_post_id' => existing.id)
+ expect(json.fetch('field_warnings')).not_to have_key('thumbnail_base')
+ end
+
it '201 and creates post + tags when member' do
sign_in_as(member)
@@ -769,7 +875,8 @@ RSpec.describe 'Posts API', type: :request do
)
expect(response).to have_http_status(:created)
- expect(open_transactions).to eq([baseline_open_transactions])
+ expect(open_transactions).to eq(
+ [baseline_open_transactions, baseline_open_transactions])
end
it 'returns 422 and does not create a post when thumbnail resize fails' do
@@ -827,7 +934,7 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
- 'tags' => ['廃止済みタグは付与できません.']
+ 'tags' => ['廃止済みタグがあります: deprecated_direct_tag']
)
end
@@ -1251,6 +1358,47 @@ RSpec.describe 'Posts API', type: :request do
end
end
+ describe 'POST /posts/bulk' do
+ let(:member) { create(:user, :member) }
+
+ it 'parses the manifest and indexed thumbnail parts' do
+ sign_in_as(member)
+ manifest = [{ 'url' => 'https://example.com/bulk',
+ 'title' => 'bulk post',
+ 'tags' => 'spec_tag',
+ 'parent_post_ids' => '' }]
+ creator = instance_double(
+ PostBulkCreator,
+ run: { results: [{ status: 'created', post: { id: 123 } }] })
+ allow(PostBulkCreator).to receive(:new).and_return(creator)
+
+ post '/posts/bulk', params: {
+ posts: JSON.generate(manifest),
+ thumbnails: { '0' => real_thumbnail_upload } }
+
+ expect(response).to have_http_status(:ok)
+ expect(json.fetch('results')).to eq(
+ [{ 'status' => 'created', 'post' => { 'id' => 123 } }])
+ expect(PostBulkCreator).to have_received(:new) do |arguments|
+ expect(arguments[:actor]).to eq(member)
+ expect(arguments[:posts]).to eq(manifest)
+ expect(arguments[:thumbnails].keys).to eq([0])
+ expect(arguments[:host]).to eq('http://www.example.com')
+ end
+ end
+
+ it 'rejects malformed manifests as a request-level error' do
+ sign_in_as(member)
+
+ post '/posts/bulk', params: {
+ posts: '{',
+ thumbnails: { '0' => real_thumbnail_upload } }
+
+ expect(response).to have_http_status(:bad_request)
+ expect(json.fetch('message')).to eq('posts manifest の JSON が不正です.')
+ end
+ end
+
describe 'PUT /posts/:id' do
let(:member) { create(:user, :member) }
@@ -2271,9 +2419,9 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:created)
expect(Time.iso8601(json.fetch('original_created_from')))
- .to eq(Time.iso8601('2020-01-01T00:00Z'))
+ .to eq(Time.utc(2020, 1, 1, 0, 0))
expect(Time.iso8601(json.fetch('original_created_before')))
- .to eq(Time.iso8601('2020-01-01T00:01Z'))
+ .to eq(Time.utc(2020, 1, 1, 0, 1))
end
it 'rejects unparseable original created timestamps on PUT /posts/:id' do
diff --git a/backend/spec/services/post_bulk_creator_spec.rb b/backend/spec/services/post_bulk_creator_spec.rb
new file mode 100644
index 0000000..0779661
--- /dev/null
+++ b/backend/spec/services/post_bulk_creator_spec.rb
@@ -0,0 +1,59 @@
+require 'rails_helper'
+
+RSpec.describe PostBulkCreator do
+ it 'limits workers to two and keeps failures in their request slots' do
+ actor = instance_double(User, id: 123)
+ allow(User).to receive(:find).with(123) {
+ instance_double(User, id: 123)
+ }
+ mutex = Mutex.new
+ active = 0
+ maximum_active = 0
+
+ allow(PostCreatePreflight).to receive(:new) do |attributes:, **|
+ preflight = instance_double(PostCreatePreflight)
+ allow(preflight).to receive(:run) do
+ mutex.synchronize do
+ active += 1
+ maximum_active = [maximum_active, active].max
+ end
+ sleep 0.02
+ mutex.synchronize { active -= 1 }
+ attributes.symbolize_keys.merge(
+ existing_post_id: nil,
+ field_warnings: { },
+ base_warnings: [])
+ end
+ preflight
+ end
+ allow(PostCreator).to receive(:new) do |attributes:, **|
+ creator = instance_double(PostCreator)
+ if attributes[:title] == 'broken'
+ allow(creator).to receive(:create!).and_raise(StandardError, 'broken')
+ else
+ post = instance_double(Post, id: attributes[:title].delete_prefix('post ').to_i)
+ allow(creator).to receive(:create!).and_return(post)
+ end
+ creator
+ end
+ posts = [
+ { 'title' => 'post 1', 'url' => 'https://example.com/1' },
+ { 'title' => 'broken', 'url' => 'https://example.com/2' },
+ { 'title' => 'post 3', 'url' => 'https://example.com/3' },
+ { 'title' => 'post 4', 'url' => 'https://example.com/4' }]
+
+ results = described_class.new(
+ actor:,
+ posts:,
+ thumbnails: { }).run.fetch(:results)
+
+ expect(maximum_active).to eq(2)
+ expect(results.length).to eq(posts.length)
+ expect(results.map { _1[:status] }).to eq(
+ ['created', 'failed', 'created', 'created'])
+ expect(results[0].dig(:post, :id)).to eq(1)
+ expect(results[1]).to include(status: 'failed', recoverable: false)
+ expect(results[2].dig(:post, :id)).to eq(3)
+ expect(results[3].dig(:post, :id)).to eq(4)
+ end
+end
diff --git a/backend/spec/services/post_create_plan_spec.rb b/backend/spec/services/post_create_plan_spec.rb
new file mode 100644
index 0000000..5558d81
--- /dev/null
+++ b/backend/spec/services/post_create_plan_spec.rb
@@ -0,0 +1,82 @@
+require 'rails_helper'
+
+RSpec.describe PostCreatePlan do
+ def create_tag! name, category
+ Tag.create!(name:, category:)
+ end
+
+ before do
+ create_tag!('タグ希望', :meta)
+ create_tag!('ニジラー情報不詳', :meta)
+ end
+
+ it 'plans direct and existing default tags without persisting records' do
+ counts = [TagName.count, Tag.count]
+
+ plan = described_class.new(
+ attributes: {
+ url: 'https://example.com/post',
+ title: 'title',
+ tags: 'character:new_character',
+ parent_post_ids: '' }).build!
+
+ expect(plan[:tags]).to eq('new_character')
+ expect(plan[:direct_tag_specs]).to eq(
+ [{ name: 'new_character', category: :character }])
+ expect(plan[:default_tag_specs]).to include(
+ { name: 'タグ希望', category: :meta },
+ { name: 'ニジラー情報不詳', category: :meta })
+ expect([TagName.count, Tag.count]).to eq(counts)
+ end
+
+ it 'resolves aliases and keeps tag sections separate from canonical names' do
+ canonical = create_tag!('虹夏', :character)
+ TagName.create!(name: 'にじか', canonical: canonical.tag_name)
+ create_tag!('動画', :meta)
+
+ plan = described_class.new(
+ attributes: {
+ url: 'https://example.com/video',
+ title: 'video',
+ tags: '動画 にじか[0:10-0:20]',
+ duration: '1:00',
+ parent_post_ids: '' }).build!
+
+ expect(plan[:tags].split).to include('動画', '虹夏[0:10-0:20]')
+ expect(plan[:display_tags]).to include(
+ { name: '虹夏',
+ category: 'character',
+ section_literals: ['[0:10-0:20]'] })
+ expect(plan[:video_ms]).to eq(60_000)
+ end
+
+ it 'validates a new tag name without persisting it' do
+ long_name = 'a' * 256
+ counts = [TagName.count, Tag.count]
+
+ expect {
+ described_class.new(
+ attributes: {
+ url: 'https://example.com/post',
+ title: 'title',
+ tags: long_name,
+ parent_post_ids: '' }).build!
+ }.to raise_error(ActiveRecord::RecordInvalid) { |error|
+ expect(error.record.errors[:tags]).not_to be_empty
+ }
+ expect([TagName.count, Tag.count]).to eq(counts)
+ end
+
+ it 'ignores duration when the planned tags do not include video' do
+ plan = described_class.new(
+ attributes: {
+ url: 'https://example.com/post',
+ title: 'title',
+ tags: 'ordinary_tag',
+ duration: 'invalid',
+ parent_post_ids: '' }).build!
+
+ expect(plan[:duration]).to eq('invalid')
+ expect(plan[:video_ms]).to be_nil
+ end
+end
diff --git a/backend/spec/services/post_creator_spec.rb b/backend/spec/services/post_creator_spec.rb
index 29a069d..cadf8a1 100644
--- a/backend/spec/services/post_creator_spec.rb
+++ b/backend/spec/services/post_creator_spec.rb
@@ -20,12 +20,12 @@ RSpec.describe PostCreator do
allow(PostVersionRecorder).to receive(:record!)
end
- it 'prefers an explicit upload over thumbnail_base' do
- allow(Post).to receive(:resized_thumbnail_attachment).and_return(
- io: StringIO.new('upload'),
+ it 'prefers thumbnail_base over an explicit upload' do
+ expect(Post).not_to receive(:resized_thumbnail_attachment)
+ allow(Post).to receive(:remote_thumbnail_attachment).and_return(
+ io: StringIO.new('remote'),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg')
- expect_any_instance_of(Post).not_to receive(:attach_thumbnail_from_url!)
post = described_class.new(
actor:,
@@ -41,13 +41,12 @@ RSpec.describe PostCreator do
end
it 'uses the common remote thumbnail attach path when thumbnail_base is given' do
- expect_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
- .with('https://example.com/thumb.jpg') do |post, _url|
- post.thumbnail.attach(
- io: StringIO.new('thumbnail'),
- filename: 'thumbnail.jpg',
- content_type: 'image/jpeg')
- end
+ expect(Post).to receive(:remote_thumbnail_attachment)
+ .with('https://example.com/thumb.jpg')
+ .and_return(
+ io: StringIO.new('thumbnail'),
+ filename: 'thumbnail.jpg',
+ content_type: 'image/jpeg')
post = described_class.new(
actor:,
@@ -61,8 +60,8 @@ RSpec.describe PostCreator do
expect(post.thumbnail).to be_attached
end
- it 'keeps creating the post and records a warning when remote thumbnail fetch fails' do
- allow_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
+ it 'does not create a post when remote thumbnail fetch fails' do
+ allow(Post).to receive(:remote_thumbnail_attachment)
.and_raise(Post::RemoteThumbnailFetchFailed, 'サムネール画像を取得できませんでした.')
creator = described_class.new(
actor:,
@@ -72,11 +71,8 @@ RSpec.describe PostCreator do
thumbnail_base: 'https://example.com/thumb.jpg',
tags: '' })
- post = creator.create!
-
- expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
- expect(post.thumbnail).not_to be_attached
- expect(creator.field_warnings).to eq(
- thumbnail_base: ['サムネール画像を取得できませんでした.'])
+ post_count = Post.count
+ expect { creator.create! }.to raise_error(Post::RemoteThumbnailFetchFailed)
+ expect(Post.count).to eq(post_count)
end
end
diff --git a/backend/spec/services/post_import_row_normaliser_spec.rb b/backend/spec/services/post_import_row_normaliser_spec.rb
deleted file mode 100644
index 5393228..0000000
--- a/backend/spec/services/post_import_row_normaliser_spec.rb
+++ /dev/null
@@ -1,77 +0,0 @@
-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' },
- 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' },
- '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
deleted file mode 100644
index 00828d2..0000000
--- a/backend/spec/services/post_import_runner_spec.rb
+++ /dev/null
@@ -1,152 +0,0 @@
-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
-
- it 'returns thumbnail warnings from PostCreator on a created row' do
- previewer = instance_double(PostImportPreviewer)
- allow(PostImportPreviewer).to receive(:new).and_return(previewer)
- allow(previewer).to receive(:preview_rows).and_return([preview])
- created_post = create(:post)
- creator = instance_double(
- PostCreator,
- create!: created_post,
- field_warnings: { thumbnail_base: ['サムネール画像を取得できませんでした.'] })
- allow(PostCreator).to receive(:new).and_return(creator)
-
- result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
-
- expect(result).to include(
- status: 'created',
- field_warnings: { thumbnail_base: ['サムネール画像を取得できませんでした.'] }
- )
- 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
deleted file mode 100644
index 0704abc..0000000
--- a/backend/spec/services/post_import_url_list_parser_spec.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-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_thumbnail_upload_validator_spec.rb b/backend/spec/services/post_thumbnail_upload_validator_spec.rb
new file mode 100644
index 0000000..ddf495b
--- /dev/null
+++ b/backend/spec/services/post_thumbnail_upload_validator_spec.rb
@@ -0,0 +1,52 @@
+require 'rails_helper'
+require 'base64'
+require 'tempfile'
+
+RSpec.describe PostThumbnailUploadValidator do
+ def with_upload bytes, content_type:, filename:
+ tempfile = Tempfile.new(['thumbnail-upload', File.extname(filename)])
+ tempfile.binmode
+ tempfile.write(bytes)
+ tempfile.rewind
+ upload = ActionDispatch::Http::UploadedFile.new(
+ tempfile:,
+ filename:,
+ type: content_type)
+ yield upload
+ ensure
+ tempfile&.close!
+ end
+
+ it 'accepts a raster upload after decoding and rewinds it' do
+ gif = Base64.decode64('R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')
+
+ with_upload(gif, content_type: 'image/gif', filename: 'thumbnail.gif') do |upload|
+ expect { described_class.validate!(upload) }.not_to raise_error
+ expect(upload.read(6)).to eq('GIF87a')
+ end
+ end
+
+ it 'rejects SVG content disguised as a raster MIME type' do
+ with_upload(
+ '',
+ content_type: 'image/png',
+ filename: 'thumbnail.png') do |upload|
+ expect { described_class.validate!(upload) }
+ .to raise_error(
+ described_class::InvalidUpload,
+ 'サムネイル画像の形式が不正です.')
+ end
+ end
+
+ it 'rejects non-raster content disguised as an image' do
+ with_upload(
+ '%PDF-1.7',
+ content_type: 'image/png',
+ filename: 'thumbnail.png') do |upload|
+ expect { described_class.validate!(upload) }
+ .to raise_error(
+ described_class::InvalidUpload,
+ 'サムネイル画像の形式が不正です.')
+ end
+ end
+end
diff --git a/backend/spec/services/preview/thumbnail_fetcher_spec.rb b/backend/spec/services/preview/thumbnail_fetcher_spec.rb
index 16bb8cc..1856865 100644
--- a/backend/spec/services/preview/thumbnail_fetcher_spec.rb
+++ b/backend/spec/services/preview/thumbnail_fetcher_spec.rb
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe Preview::ThumbnailFetcher do
describe '.fetch' do
- it 'rejects svg thumbnails' do
+ it 'accepts svg thumbnails for the common safe rasterisation path' do
page = Preview::HttpFetcher::Response.new(
'',
'text/html',
@@ -12,8 +12,9 @@ RSpec.describe Preview::ThumbnailFetcher do
'image/svg+xml',
'https://example.com/thumb.svg')
- allow(Preview::UrlSafety).to receive(:validate)
- .and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
+ allow(Preview::UrlSafety).to receive(:validate) do |url|
+ [URI.parse(url), ['203.0.113.10']]
+ end
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
.and_return(page)
@@ -21,9 +22,7 @@ RSpec.describe Preview::ThumbnailFetcher do
.with('https://example.com/thumb.svg')
.and_return(svg)
- expect {
- described_class.fetch('https://example.com/page')
- }.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
+ expect(described_class.fetch('https://example.com/page')).to eq('')
end
it 'accepts allowed image content type with parameters' do
@@ -32,12 +31,13 @@ RSpec.describe Preview::ThumbnailFetcher do
'text/html',
'https://example.com/page')
image = Preview::HttpFetcher::Response.new(
- 'jpeg-bytes',
+ "\xFF\xD8\xFFjpeg-bytes".b,
'image/jpeg; charset=binary',
'https://example.com/thumb.jpg')
- allow(Preview::UrlSafety).to receive(:validate)
- .and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
+ allow(Preview::UrlSafety).to receive(:validate) do |url|
+ [URI.parse(url), ['203.0.113.10']]
+ end
allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
.and_return(page)
@@ -45,7 +45,8 @@ RSpec.describe Preview::ThumbnailFetcher do
.with('https://example.com/thumb.jpg')
.and_return(image)
- expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes')
+ expect(described_class.fetch('https://example.com/page'))
+ .to eq("\xFF\xD8\xFFjpeg-bytes".b)
end
end
diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb
index fb71e4a..26b3bb3 100644
--- a/backend/spec/tasks/nico_sync_spec.rb
+++ b/backend/spec/tasks/nico_sync_spec.rb
@@ -73,11 +73,11 @@ RSpec.describe "nico:sync" do
Tag.tagme
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
- allow(URI).to receive(:open)
- .and_return(
- StringIO.new(
- ''))
- expect(post).to receive(:attach_thumbnail_from_url!)
+ allow(URI).to receive(:open) do
+ StringIO.new(
+ '')
+ end
+ expect_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
.with('https://example.com/thumb.jpg')
run_rake_task('nico:sync')
@@ -92,18 +92,20 @@ RSpec.describe "nico:sync" do
Tag.tagme
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
- allow(URI).to receive(:open)
- .and_return(
- StringIO.new(
- ''))
- expect(post).to receive(:attach_thumbnail_from_url!)
- .with('https://example.com/thumb.jpg')
- .twice
- .and_raise(Post::RemoteThumbnailFetchFailed, 'failed')
+ allow(URI).to receive(:open) do
+ StringIO.new(
+ '')
+ end
+ calls = 0
+ allow_any_instance_of(Post).to receive(:attach_thumbnail_from_url!) do
+ calls += 1
+ raise Post::RemoteThumbnailFetchFailed, 'failed'
+ end
2.times do
run_rake_task('nico:sync')
end
+ expect(calls).to eq(2)
end
it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do
diff --git a/frontend/src/components/PostEditForm.test.tsx b/frontend/src/components/PostEditForm.test.tsx
index fe9eefc..769d04e 100644
--- a/frontend/src/components/PostEditForm.test.tsx
+++ b/frontend/src/components/PostEditForm.test.tsx
@@ -78,16 +78,18 @@ describe ('PostEditForm', () => {
render ()
- expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
+ expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
+ .toHaveValue ('180.5')
const tags = screen.getAllByRole ('textbox')[2]
fireEvent.change (tags, { target: { value: 'general-tag' } })
- expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
+ expect (screen.queryByText ('動画時間')).not.toBeInTheDocument ()
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
- expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
+ expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
+ .toHaveValue ('180.5')
})
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
diff --git a/frontend/src/components/PostOriginalCreatedTimeField.test.tsx b/frontend/src/components/PostOriginalCreatedTimeField.test.tsx
index dc6ba4b..37186e2 100644
--- a/frontend/src/components/PostOriginalCreatedTimeField.test.tsx
+++ b/frontend/src/components/PostOriginalCreatedTimeField.test.tsx
@@ -73,7 +73,7 @@ describe ('PostOriginalCreatedTimeField', () => {
setOriginalCreatedBefore={vi.fn ()}/>,
)
- const input = screen.getDisplayValue ('2024-01-01T12:34')
+ const input = screen.getByDisplayValue ('2024-01-01T12:34')
fireEvent.change (input, { target: { value: '2024-01-01T12:35' } })
expect (setFrom).toHaveBeenCalledWith ('2024-01-01T03:35Z')
diff --git a/frontend/src/components/TopNav.test.tsx b/frontend/src/components/TopNav.test.tsx
index 5220b4f..de954eb 100644
--- a/frontend/src/components/TopNav.test.tsx
+++ b/frontend/src/components/TopNav.test.tsx
@@ -17,21 +17,19 @@ describe ('menuOutline', () => {
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 ('uses /posts/new as the import entrypoint', () => {
- expect (submenuItem ('member', '広場', '取込')?.to).toBe ('/posts/new')
+ it ('uses /posts/new as the post creation entrypoint', () => {
+ expect (submenuItem ('member', '広場', '追加')?.to).toBe ('/posts/new')
})
it ('keeps material suppression admin-only', () => {
diff --git a/frontend/src/components/dialogues/DialogueProvider.test.tsx b/frontend/src/components/dialogues/DialogueProvider.test.tsx
index d2bc4f8..171631f 100644
--- a/frontend/src/components/dialogues/DialogueProvider.test.tsx
+++ b/frontend/src/components/dialogues/DialogueProvider.test.tsx
@@ -80,7 +80,9 @@ describe ('DialogueProvider', () => {
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')
+ expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass (
+ 'w-full',
+ 'md:w-auto')
fireEvent.click (screen.getByRole ('button', { name: '左操作' }))
await waitFor (() => expect (action).toHaveBeenCalledTimes (1))
diff --git a/frontend/src/components/posts/PostCreationDataFieldsUsage.test.tsx b/frontend/src/components/posts/PostCreationDataFieldsUsage.test.tsx
index 4af4df6..d617eb9 100644
--- a/frontend/src/components/posts/PostCreationDataFieldsUsage.test.tsx
+++ b/frontend/src/components/posts/PostCreationDataFieldsUsage.test.tsx
@@ -2,8 +2,6 @@ import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { buildPostImportRow } from '@/test/postImportFactories'
-import { buildUser } from '@/test/factories'
-import { renderWithProviders } from '@/test/render'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
@@ -14,14 +12,6 @@ vi.mock ('@/components/posts/PostCreationDataFields', () => ({
}))
describe ('PostCreationDataFields usage', () => {
- it ('is used by PostNewPage', async () => {
- const { default: PostNewPage } = await import ('@/pages/posts/PostNewPage')
-
- renderWithProviders ()
-
- expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
- })
-
it ('is used by PostImportRowForm', async () => {
const { default: PostImportRowForm } = await import (
'@/components/posts/import/PostImportRowForm')
diff --git a/frontend/src/components/posts/import/PostImportRowForm.test.tsx b/frontend/src/components/posts/import/PostImportRowForm.test.tsx
index 6759781..ad33957 100644
--- a/frontend/src/components/posts/import/PostImportRowForm.test.tsx
+++ b/frontend/src/components/posts/import/PostImportRowForm.test.tsx
@@ -135,7 +135,7 @@ describe ('PostImportRowForm', () => {
})
it (
- 'shows the shared creation field order without duration and without file upload UI',
+ 'shows upload input only when the thumbnail URL is blank',
async () => {
let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = {
@@ -164,7 +164,9 @@ describe ('PostImportRowForm', () => {
'オリジナルの作成日時',
'タグ',
'親投稿'])
- expect (container.querySelector ('input[type="file"]')).toBeNull ()
+ expect (container.querySelector ('input[type="file"]')).toHaveAttribute (
+ 'accept',
+ 'image/*')
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
diff --git a/frontend/src/components/posts/import/PostImportThumbnailPreview.test.tsx b/frontend/src/components/posts/import/PostImportThumbnailPreview.test.tsx
index 16a3ed4..63879a7 100644
--- a/frontend/src/components/posts/import/PostImportThumbnailPreview.test.tsx
+++ b/frontend/src/components/posts/import/PostImportThumbnailPreview.test.tsx
@@ -1,14 +1,8 @@
-import { render, screen, waitFor } from '@testing-library/react'
+import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
-const api = vi.hoisted (() => ({
- apiGet: vi.fn (),
-}))
-
-vi.mock ('@/lib/api', () => api)
-
describe ('PostImportThumbnailPreview', () => {
beforeEach (() => {
vi.clearAllMocks ()
@@ -16,69 +10,50 @@ describe ('PostImportThumbnailPreview', () => {
globalThis.URL.revokeObjectURL = vi.fn ()
})
- it ('uses a backend-fetched blob URL instead of the external thumbnail URL directly', async () => {
- api.apiGet.mockResolvedValueOnce (new Blob (['img'], { type: 'image/png' }))
-
+ it ('renders the remote URL directly without a backend proxy', () => {
render (
- )
+ )
- await waitFor (() => {
- expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
- })
- expect (screen.getByRole ('img')).not.toHaveAttribute (
- 'src',
- 'https://example.com/thumbnail.jpg')
- expect (api.apiGet).toHaveBeenCalledWith ('/preview/thumbnail', {
- params: { url: 'https://example.com/thumbnail.jpg' },
- responseType: 'blob' })
+ expect (screen.getByRole ('img')).toHaveAttribute (
+ 'src',
+ 'https://example.com/thumbnail.jpg')
+ expect (screen.getByRole ('img')).toHaveAttribute (
+ 'referrerpolicy',
+ 'no-referrer')
})
- it ('does not render the unsafe URL directly when preview fetching fails', async () => {
- api.apiGet.mockRejectedValueOnce (new Error ('unsafe'))
-
+ it ('shows the empty frame after the remote image fails', () => {
const { container } = render (
- )
+ )
- await waitFor (() => {
- expect (screen.queryByRole ('img')).toBeNull ()
- })
- expect (screen.queryByText ('サムネールを表示できません')).toBeNull ()
- expect (screen.queryByText ('なし')).toBeNull ()
+ fireEvent.error (screen.getByRole ('img'))
+
+ expect (screen.queryByRole ('img')).toBeNull ()
expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull ()
expect (container.textContent).toBe ('')
- expect (screen.queryByRole ('img')).toBeNull ()
})
- it ('revokes the old object URL when the source URL changes', async () => {
- const createObjectUrlMock =
- globalThis.URL.createObjectURL as unknown as ReturnType
- createObjectUrlMock
- .mockReturnValueOnce ('blob:first')
- .mockReturnValueOnce ('blob:second')
- api.apiGet
- .mockResolvedValueOnce (new Blob (['first'], { type: 'image/png' }))
- .mockResolvedValueOnce (new Blob (['second'], { type: 'image/png' }))
+ it ('uses and revokes an object URL only when the remote URL is blank', () => {
+ const file = new File (['image'], 'thumbnail.png', { type: 'image/png' })
+ const { rerender, unmount } = render (
+ )
- const { rerender } = render (
- )
- await waitFor (() => {
- expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:first')
- })
+ expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
rerender (
- )
+ )
+ expect (screen.getByRole ('img')).toHaveAttribute (
+ 'src',
+ 'https://example.com/remote.jpg')
- await waitFor (() => {
- expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:second')
- })
- expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:first')
+ unmount ()
+ expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:preview')
})
})
diff --git a/frontend/src/components/posts/import/postImportRowStatus.test.ts b/frontend/src/components/posts/import/postImportRowStatus.test.ts
index 7ea08bf..1f61191 100644
--- a/frontend/src/components/posts/import/postImportRowStatus.test.ts
+++ b/frontend/src/components/posts/import/postImportRowStatus.test.ts
@@ -16,14 +16,14 @@ describe ('displayPostImportStatus', () => {
skipReason: 'manual' }))).toBe ('skipped')
})
- it ('does not expose validation, failure, or created states as badges', () => {
+ it ('distinguishes validation, failure, and created states', () => {
expect (displayPostImportStatus (buildPostImportRow ({
status: 'error',
- validationErrors: { title: ['invalid'] } }))).toBeNull ()
+ validationErrors: { title: ['invalid'] } }))).toBe ('error')
expect (displayPostImportStatus (buildPostImportRow ({
- importStatus: 'failed' }))).toBeNull ()
+ importStatus: 'failed' }))).toBe ('failed')
expect (displayPostImportStatus (buildPostImportRow ({
importStatus: 'created',
- createdPostId: 3 }))).toBeNull ()
+ createdPostId: 3 }))).toBe ('created')
})
})
diff --git a/frontend/src/lib/postImportRows.test.ts b/frontend/src/lib/postImportRows.test.ts
index 2847475..f239f0f 100644
--- a/frontend/src/lib/postImportRows.test.ts
+++ b/frontend/src/lib/postImportRows.test.ts
@@ -16,7 +16,7 @@ import { creatableImportRows,
resultRowMessages,
resultSummaryCounts,
retryImportRow,
- reviewSummaryCounts } from '@/lib/postImportSession'
+ reviewSummaryCounts } from '@/lib/postImportRows'
import { buildPostImportRow } from '@/test/postImportFactories'
describe ('post import row state', () => {
@@ -274,6 +274,7 @@ describe ('post import row state', () => {
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
+ duration: '2',
tags: 'edited-tag',
parentPostIds: '' },
true)
diff --git a/frontend/src/lib/postImportSourceValidation.test.ts b/frontend/src/lib/postImportSourceValidation.test.ts
index 2f565f5..91f0484 100644
--- a/frontend/src/lib/postImportSourceValidation.test.ts
+++ b/frontend/src/lib/postImportSourceValidation.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
-import { countImportSourceLines, validateImportSource } from '@/lib/postImportSession'
+import {
+ countImportSourceLines,
+ validateImportSource,
+} from '@/lib/postImportSourceValidation'
describe ('post import source validation', () => {
it ('counts trimmed non-empty CRLF and LF rows', () => {
diff --git a/frontend/src/lib/postImportStorage.test.ts b/frontend/src/lib/postImportStorage.test.ts
index 91f7baa..832971a 100644
--- a/frontend/src/lib/postImportStorage.test.ts
+++ b/frontend/src/lib/postImportStorage.test.ts
@@ -1,124 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { cleanupExpiredPostImportSessions,
- clearPostImportSourceDraft,
- loadPostImportSession,
- loadPostImportSourceDraft,
- savePostImportSession,
- savePostImportSourceDraft } from '@/lib/postImportSession'
-import { buildPostImportRow } from '@/test/postImportFactories'
+import {
+ clearPostImportSourceDraft,
+ loadPostImportSourceDraft,
+ savePostImportSourceDraft,
+} from '@/lib/postImportStorage'
-describe ('post import storage', () => {
+describe ('post import source draft storage', () => {
beforeEach (() => {
sessionStorage.clear ()
- vi.useRealTimers ()
+ vi.restoreAllMocks ()
})
- it ('round-trips a valid session and source draft', () => {
- const row = buildPostImportRow ({
- attributes: { duration: '2.5' },
- recoverable: true,
- importStatus: 'pending',
- validationErrors: { title: ['invalid'] } })
- const skipped = buildPostImportRow ({
- importStatus: 'skipped',
- skipReason: 'existing',
- existingPostId: 10 })
- const manual = buildPostImportRow ({
- sourceRow: 3,
- skipReason: 'manual' })
-
- expect (savePostImportSession ({
- source: skipped.url,
- rows: [row, skipped, manual],
- repairMode: 'all' })).toBe (true)
- expect (loadPostImportSession ()).toMatchObject ({
- version: 2,
- source: skipped.url,
- rows: [{
- attributes: { duration: '2.5' },
- recoverable: true,
- importStatus: 'pending' },
- {
- importStatus: 'skipped',
- skipReason: 'existing',
- existingPostId: 10 },
- {
- skipReason: 'manual' }] })
-
+ it ('round-trips and clears the URL list source draft', () => {
expect (savePostImportSourceDraft ('https://example.com')).toBe (true)
- expect (loadPostImportSourceDraft ()).toEqual ({ source: 'https://example.com' })
+ 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 },
- { ...session.rows[0], importStatus: 'created', recoverable: true },
- { ...session.rows[0], importStatus: 'skipped', recoverable: true },
- { ...session.rows[0], recoverable: true }]
+ it ('ignores malformed stored drafts', () => {
+ sessionStorage.setItem ('post-import-source-draft', '{')
- for (const row of invalidRows)
- {
- sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
- ...session,
- rows: [row] }))
- expect (loadPostImportSession ()).toBeNull ()
- sessionStorage.clear ()
- }
- })
-
- 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 => {
- sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
- version: 2,
- savedAt: new Date ().toISOString (),
- source: '',
- rows: [invalidRow],
- repairMode: 'all' }))
- expect (loadPostImportSession ()).toBeNull ()
- sessionStorage.clear ()
- })
- })
-
- it ('keeps only the current fixed-key session and removes legacy prefixed entries', () => {
- 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 ()
+ expect (loadPostImportSourceDraft ()).toEqual ({ source: '' })
})
it ('reports storage access failures without throwing', () => {
diff --git a/frontend/src/lib/postNewQueryState.test.ts b/frontend/src/lib/postNewQueryState.test.ts
new file mode 100644
index 0000000..99dec3e
--- /dev/null
+++ b/frontend/src/lib/postNewQueryState.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ buildPostNewReviewPath,
+ hasPostNewReviewState,
+ isPostNewReviewPathWithinLimit,
+ parsePostNewReviewUrls,
+ postNewReviewPathByteLength,
+} from '@/lib/postNewQueryState'
+
+describe ('post new review URL state', () => {
+ it ('encodes each URL separately and preserves literal plus signs', () => {
+ const urls = [
+ 'https://example.com/one+a',
+ 'https://example.com/two?value=b+c']
+
+ const path = buildPostNewReviewPath (urls)
+
+ expect (path).toBe (
+ '/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
+ + '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc')
+ expect (parsePostNewReviewUrls (path.slice ('/posts/new'.length))).toEqual (urls)
+ })
+
+ it ('uses only the raw urls parameter as review state', () => {
+ expect (hasPostNewReviewState ('?session_id=old&meta=old')).toBe (false)
+ expect (hasPostNewReviewState ('?unknown=value&urls=')).toBe (true)
+ expect (parsePostNewReviewUrls ('?unknown=value&urls=one+two&meta=old'))
+ .toEqual (['one', 'two'])
+ })
+
+ it ('allows at most a 4095-byte request target', () => {
+ const baseUrl = 'https://example.com/'
+ const baseLength = postNewReviewPathByteLength ([baseUrl])
+ const allowed = `${ baseUrl }${ 'a'.repeat (4_095 - baseLength) }`
+ const denied = `${ allowed }a`
+
+ expect (postNewReviewPathByteLength ([allowed])).toBe (4_095)
+ expect (isPostNewReviewPathWithinLimit ([allowed])).toBe (true)
+ expect (postNewReviewPathByteLength ([denied])).toBe (4_096)
+ expect (isPostNewReviewPathWithinLimit ([denied])).toBe (false)
+ })
+})
diff --git a/frontend/src/lib/postNewQueryState.ts b/frontend/src/lib/postNewQueryState.ts
index 86bcb4e..cf55fd3 100644
--- a/frontend/src/lib/postNewQueryState.ts
+++ b/frontend/src/lib/postNewQueryState.ts
@@ -1,5 +1,5 @@
const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls='
-const MAX_POST_NEW_REVIEW_TARGET_BYTES = 6_144
+const MAX_POST_NEW_REVIEW_TARGET_BYTES = 4_096
const textEncoder = new TextEncoder ()
diff --git a/frontend/src/lib/useUnsavedChangesGuard.test.tsx b/frontend/src/lib/useUnsavedChangesGuard.test.tsx
new file mode 100644
index 0000000..8c945ba
--- /dev/null
+++ b/frontend/src/lib/useUnsavedChangesGuard.test.tsx
@@ -0,0 +1,77 @@
+import { fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { useState } from 'react'
+import { useLocation, useNavigate } from 'react-router-dom'
+import { describe, expect, it, vi } from 'vitest'
+
+import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
+import { renderWithProviders } from '@/test/render'
+
+const GuardHarness = () => {
+ const [dirty, setDirty] = useState (true)
+ const location = useLocation ()
+ const navigate = useNavigate ()
+ const discard = vi.fn (() => setDirty (false))
+ const { allowNextNavigation } = useUnsavedChangesGuard ({
+ dirty,
+ onDiscard: discard })
+
+ return (
+ <>
+
+
+
+ >)
+}
+
+describe ('useUnsavedChangesGuard', () => {
+ it ('blocks route changes and resets a cancelled transition', async () => {
+ renderWithProviders (, { route: '/current' })
+
+ fireEvent.click (screen.getByRole ('button', { name: 'move' }))
+
+ expect (within (await screen.findByRole ('dialog')).getByText (
+ '変更が破棄してページ移動しますか?')).toBeInTheDocument ()
+ expect (screen.getByLabelText ('location')).toHaveTextContent ('/current')
+
+ fireEvent.click (screen.getByRole ('button', { name: '取消' }))
+
+ await waitFor (() => {
+ expect (screen.queryByRole ('dialog')).not.toBeInTheDocument ()
+ })
+ expect (screen.getByLabelText ('location')).toHaveTextContent ('/current')
+ })
+
+ it ('proceeds with the blocked transition after discard is confirmed', async () => {
+ renderWithProviders (, { route: '/current' })
+
+ fireEvent.click (screen.getByRole ('button', { name: 'move' }))
+ fireEvent.click (await screen.findByRole ('button', {
+ name: '変更を破棄して移動' }))
+
+ await waitFor (() => {
+ expect (screen.getByLabelText ('location')).toHaveTextContent ('/next?tab=one')
+ })
+ })
+
+ it ('allows only the next navigation without leaving a bypass token', async () => {
+ renderWithProviders (, { route: '/current' })
+
+ fireEvent.click (screen.getByRole ('button', { name: 'allowed move' }))
+ await waitFor (() => {
+ expect (screen.getByLabelText ('location')).toHaveTextContent ('/allowed')
+ })
+
+ fireEvent.click (screen.getByRole ('button', { name: 'move' }))
+
+ expect (within (await screen.findByRole ('dialog')).getByText (
+ '変更が破棄してページ移動しますか?')).toBeInTheDocument ()
+ expect (screen.getByLabelText ('location')).toHaveTextContent ('/allowed')
+ })
+})
diff --git a/frontend/src/lib/useUnsavedChangesGuard.tsx b/frontend/src/lib/useUnsavedChangesGuard.tsx
index 422d259..868e538 100644
--- a/frontend/src/lib/useUnsavedChangesGuard.tsx
+++ b/frontend/src/lib/useUnsavedChangesGuard.tsx
@@ -38,6 +38,7 @@ export const UnsavedChangesGuardProvider: FC = ({ children })
const bypassNextNavigationRef = useRef (null)
const [revision, setRevision] = useState (0)
const dialogueOpenRef = useRef (false)
+ const handlingBlockedTransitionRef = useRef (false)
const registerUnsavedChangesSource = useCallback ((
source: UnsavedChangesSource,
@@ -73,6 +74,8 @@ export const UnsavedChangesGuardProvider: FC = ({ children })
[sources],
)
const hasUnsavedChanges = dirtySources.length > 0
+ const hasUnsavedChangesRef = useRef (hasUnsavedChanges)
+ hasUnsavedChangesRef.current = hasUnsavedChanges
const shouldBlock = useCallback (() => {
if (bypassNextNavigationRef.current != null)
{
@@ -80,11 +83,11 @@ export const UnsavedChangesGuardProvider: FC = ({ children })
return false
}
- return hasUnsavedChanges
- }, [hasUnsavedChanges])
+ return hasUnsavedChangesRef.current
+ }, [])
const blocker = useBlocker (shouldBlock)
- const discardDirtyChanges = useCallback (async (): Promise => {
+ const confirmDiscardChanges = useCallback (async (): Promise => {
if (!(hasUnsavedChanges))
return true
@@ -96,54 +99,55 @@ export const UnsavedChangesGuardProvider: FC = ({ children })
try
{
const confirmed = await dialogue.confirm ({
- title: '変更を破棄してページ移動しますか?',
+ title: '変更が破棄してページ移動しますか?',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
return false
- try
- {
- for (const source of dirtySources)
- await source.discard ()
- }
- catch
- {
- return false
- }
-
return true
}
finally
{
dialogueOpenRef.current = false
}
- }, [dialogue, dirtySources, hasUnsavedChanges])
+ }, [dialogue, hasUnsavedChanges])
useEffect (() => {
- if (blocker.state !== 'blocked')
+ if (blocker.state !== 'blocked' || handlingBlockedTransitionRef.current)
return
- let active = true
-
+ handlingBlockedTransitionRef.current = true
void (async () => {
- const confirmed = await discardDirtyChanges ()
- if (!(active))
- return
+ try
+ {
+ const confirmed = await confirmDiscardChanges ()
+ if (!(confirmed))
+ {
+ blocker.reset ()
+ return
+ }
- if (confirmed)
+ let discardResults: Array>
+ try
{
- blocker.proceed ()
- return
+ discardResults = dirtySources.map (source => source.discard ())
+ }
+ catch
+ {
+ blocker.reset ()
+ return
}
- blocker.reset ()
+ blocker.proceed ()
+ await Promise.allSettled (discardResults)
+ }
+ finally
+ {
+ handlingBlockedTransitionRef.current = false
+ }
}) ()
-
- return () => {
- active = false
- }
- }, [blocker, discardDirtyChanges])
+ }, [blocker, confirmDiscardChanges, dirtySources])
useEffect (() => {
if (!(hasUnsavedChanges))
diff --git a/frontend/src/pages/posts/PostImportResultPage.test.tsx b/frontend/src/pages/posts/PostImportResultPage.test.tsx
deleted file mode 100644
index 2603b0c..0000000
--- a/frontend/src/pages/posts/PostImportResultPage.test.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom'
-import { describe, expect, it } from 'vitest'
-
-describe ('legacy post import routes', () => {
- it ('redirects /posts/import to /posts/new', () => {
- render (
-
-
- }/>
- SOURCE ROUTE}/>
-
- )
-
- expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
- })
-
- it ('redirects legacy review routes to /posts/new', () => {
- render (
-
-
- }/>
- SOURCE ROUTE}/>
-
- )
-
- expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
- })
-
- it ('redirects legacy result routes to /posts', () => {
- render (
-
-
- }/>
- }/>
- POSTS ROUTE}/>
-
- )
-
- expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
- })
-})
diff --git a/frontend/src/pages/posts/PostImportReviewPage.test.tsx b/frontend/src/pages/posts/PostImportReviewPage.test.tsx
index 0ad9670..9baec54 100644
--- a/frontend/src/pages/posts/PostImportReviewPage.test.tsx
+++ b/frontend/src/pages/posts/PostImportReviewPage.test.tsx
@@ -1,448 +1,157 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { HelmetProvider } from 'react-helmet-async'
-import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { loadPostImportSession, 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'
-import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
-import type { PostImportRow } from '@/lib/postImportSession'
+import type { ReactNode } from 'react'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (),
-}))
-
-const toastApi = vi.hoisted (() => ({
- toast: vi.fn (),
-}))
-
-const dialogue = vi.hoisted (() => ({
- form: vi.fn (() => Promise.resolve ()),
-}))
+ isApiError: vi.fn (() => false) }))
vi.mock ('@/lib/api', () => api)
-vi.mock ('@/components/ui/use-toast', () => toastApi)
-vi.mock ('@/lib/dialogues/useDialogue', () => ({
- default: () => dialogue,
-}))
+vi.mock ('framer-motion', () => ({
+ AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}>,
+ motion: {
+ div: ({ children }: { children?: ReactNode }) => {children}
,
+ main: ({ children }: { children?: ReactNode }) => {children} } }))
-const renderReviewPage = (route = '/posts/new/review') =>
- render (
-
-
-
- SOURCE ROUTE}/>
- }/>
- POSTS ROUTE}/>
-
-
- )
+const metadata = (url: string, title: string) => ({
+ url,
+ title,
+ thumbnailBase: 'https://example.com/thumbnail.jpg',
+ originalCreatedFrom: '',
+ originalCreatedBefore: '',
+ duration: '',
+ tags: '',
+ parentPostIds: [],
+ fieldWarnings: { },
+ baseWarnings: [],
+ displayTags: [] })
+
+
+const LocationProbe = () => {
+ const location = useLocation ()
+ return
+}
+
+
+const renderReviewPage = (urls: string[]) => {
+ const search = urls.map (url => encodeURIComponent (url)).join ('+')
+ return renderWithProviders (
+ <>
+
+
+ >,
+ { route: `/posts/new?urls=${ search }` })
+}
describe ('PostImportReviewPage', () => {
beforeEach (() => {
- sessionStorage.clear ()
vi.clearAllMocks ()
- globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
- globalThis.URL.revokeObjectURL = vi.fn ()
- api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
+ api.isApiError.mockReturnValue (false)
})
- it ('redirects to /posts/new when no current work exists', async () => {
- renderReviewPage ()
+ it ('fetches metadata with at most four concurrent requests', async () => {
+ const resolvers: Array<(value: ReturnType) => void> = []
+ api.apiGet.mockImplementation ((_path, options) =>
+ new Promise (resolve => {
+ resolvers.push (resolve)
+ const url = String (options.params.url)
+ void url
+ }))
+ const urls = Array.from (
+ { length: 6 },
+ (_, index) => `https://example.com/${ index + 1 }`)
+
+ renderReviewPage (urls)
+
+ await waitFor (() => expect (api.apiGet).toHaveBeenCalledTimes (4))
+
+ resolvers[0]?.(metadata (urls[0]!, 'first'))
+
+ expect (await screen.findAllByText ('first')).toHaveLength (2)
+ await waitFor (() => expect (api.apiGet).toHaveBeenCalledTimes (5))
+ expect (screen.getByRole ('button', { name: '追加' })).toBeDisabled ()
+
+ for (let index = 1; index < resolvers.length; ++index)
+ resolvers[index]?.(metadata (urls[index]!, `post ${ index + 1 }`))
+ })
+
+ it ('keeps successful rows when another metadata request fails', async () => {
+ api.apiGet
+ .mockResolvedValueOnce (metadata ('https://example.com/one', 'first'))
+ .mockRejectedValueOnce (new TypeError ('network'))
+
+ renderReviewPage (['https://example.com/one', 'https://example.com/two'])
+
+ expect (await screen.findAllByText ('first')).toHaveLength (2)
+ await waitFor (() => {
+ expect (screen.getAllByText ('登録不可')).toHaveLength (2)
+ })
+ expect (screen.getAllByRole ('button', { name: '編輯' })[0]).toBeEnabled ()
+ })
+
+ it ('shows existing posts with Active Storage thumbnail precedence', async () => {
+ api.apiGet.mockResolvedValue ({
+ ...metadata ('https://example.com/post', ''),
+ existingPostId: 24,
+ existingPost: {
+ id: 24,
+ title: 'existing post',
+ url: 'https://example.com/post',
+ thumbnail: 'https://example.com/storage.jpg',
+ thumbnailBase: 'https://example.com/base.jpg' } })
+
+ renderReviewPage (['https://example.com/post'])
+
+ const disclosure = await screen.findByRole ('button', {
+ name: '既存投稿による自動スキップ 1件' })
+ expect (screen.getByRole ('button', { name: '追加' })).toBeDisabled ()
+
+ fireEvent.click (disclosure)
+
+ expect ((await screen.findAllByRole ('img', { name: 'サムネール' }))[0])
+ .toHaveAttribute ('src', 'https://example.com/storage.jpg')
+ })
+
+ it ('keeps a recoverable bulk failure on the review screen', async () => {
+ api.apiGet.mockResolvedValue (
+ metadata ('https://example.com/post', 'new post'))
+ api.apiPost.mockResolvedValue ({
+ results: [{
+ status: 'failed',
+ recoverable: true,
+ errors: { title: ['invalid title'] } }] })
+
+ renderReviewPage (['https://example.com/post'])
+
+ fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
+
+ expect (await screen.findAllByText ('登録失敗')).toHaveLength (2)
+ expect (screen.getAllByText ('invalid title')).toHaveLength (2)
+ expect (screen.getAllByRole ('button', { name: '編輯' })[0]).toBeEnabled ()
+ expect (screen.getAllByRole ('button', { name: '再試行' })[0]).toBeEnabled ()
+ expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts/new?')
+ })
+
+ it ('navigates to /posts after every submitted row is created', async () => {
+ api.apiGet.mockResolvedValue (
+ metadata ('https://example.com/post', 'new post'))
+ api.apiPost.mockResolvedValue ({
+ results: [{ status: 'created', post: { id: 42 } }] })
+
+ renderReviewPage (['https://example.com/post'])
+
+ fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
await waitFor (() => {
- expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
+ expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts')
})
- })
-
- it ('restores the current work on review reload', async () => {
- savePostImportSession ({
- source: 'https://example.com/post',
- repairMode: 'all',
- rows: [buildPostImportRow ({
- sourceRow: 1,
- attributes: { title: 'restored row' } })] })
-
- renderReviewPage ()
-
- expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
- expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
- expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
- expect (await screen.findByText ('restored row')).toBeInTheDocument ()
- })
-
- it ('does not expose メタデータ to the user', async () => {
- savePostImportSession ({
- source: 'https://example.com/post',
- repairMode: 'all',
- rows: [buildPostImportRow ({
- sourceRow: 1,
- fieldWarnings: { title: ['自動取得に失敗しました.'] } })] })
-
- renderReviewPage ()
-
- expect (screen.queryByText (/メタデータ/)).not.toBeInTheDocument ()
- })
-
- it ('returns to /posts/new while keeping the current work', async () => {
- savePostImportSession ({
- source: 'https://example.com/post',
- repairMode: 'all',
- rows: [buildPostImportRow ({
- sourceRow: 1,
- attributes: { title: 'restored row' } })] })
-
- renderReviewPage ()
-
- fireEvent.click (await screen.findByRole ('button', { name: 'URL リスト入力へ戻る' }))
-
- await waitFor (() => {
- expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
- })
- expect (loadPostImportSession ()?.rows[0]?.attributes.title).toBe ('restored row')
- })
-
- it ('disables row editing and navigation while batch import is running', async () => {
- let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
- savePostImportSession ({
- source: 'https://example.com/post',
- repairMode: 'all',
- rows: [buildPostImportRow ({ sourceRow: 1 })] })
- api.apiPost.mockImplementationOnce (() =>
- new Promise<{ rows: PostImportRow[] }> (resolve => {
- resolveValidation = resolve
- }))
- api.apiPost.mockResolvedValueOnce ({
- created: 0,
- skipped: 0,
- failed: 0,
- rows: [] })
-
- renderReviewPage ()
-
- fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
-
- await waitFor (() => {
- expect (screen.getByRole ('button', { name: '編輯' })).toBeDisabled ()
- expect (screen.getByRole ('button', { name: 'URL リスト入力へ戻る' })).toBeDisabled ()
- expect (screen.getByRole ('button', { name: '取込実行' })).toBeDisabled ()
- })
-
- resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
- })
-
- it ('does not call import when validation omits a requested source row', async () => {
- savePostImportSession ({
- source: 'https://example.com/post-1\nhttps://example.com/post-2',
- repairMode: 'all',
- rows: [
- buildPostImportRow ({ sourceRow: 1 }),
- buildPostImportRow ({ sourceRow: 2 })] })
- api.apiPost.mockResolvedValueOnce ({
- rows: [buildPostImportRow ({ sourceRow: 1 })] })
-
- renderReviewPage ()
-
- fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
-
- await waitFor (() => {
- expect (toastApi.toast).toHaveBeenCalledWith (
- expect.objectContaining ({ title: '再検証結果が不完全でした' }))
- })
- expect (api.apiPost).toHaveBeenCalledTimes (1)
- })
-
- it ('navigates to /posts when all rows finish as created or skipped', async () => {
- savePostImportSession ({
- source: 'https://example.com/post',
- repairMode: 'all',
- rows: [buildPostImportRow ({ sourceRow: 1 })] })
- api.apiPost
- .mockResolvedValueOnce ({
- rows: [buildPostImportRow ({ sourceRow: 1 })] })
- .mockResolvedValueOnce ({
- created: 1,
- skipped: 0,
- failed: 0,
- rows: [{
- sourceRow: 1,
- status: 'created',
- post: { id: 1 } }] })
-
- renderReviewPage ()
-
- fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
-
- await waitFor (() => {
- expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
- })
- expect (loadPostImportSession ()).toBeNull ()
- })
-
- it ('stays on review when a recoverable failed row remains', async () => {
- savePostImportSession ({
- source: 'https://example.com/post',
- repairMode: 'all',
- rows: [buildPostImportRow ({ sourceRow: 1 })] })
- api.apiPost
- .mockResolvedValueOnce ({
- rows: [buildPostImportRow ({ sourceRow: 1 })] })
- .mockResolvedValueOnce ({
- created: 0,
- skipped: 0,
- failed: 1,
- rows: [{
- sourceRow: 1,
- status: 'failed',
- recoverable: true,
- errors: { title: ['invalid'] } }] })
-
- renderReviewPage ()
-
- fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
-
- expect (await screen.findByText ('invalid')).toBeInTheDocument ()
- expect (screen.queryByText ('POSTS ROUTE')).not.toBeInTheDocument ()
- })
-
- it ('keeps edited recoverable rows in session before the next batch submit', async () => {
- savePostImportSession ({
- source: 'https://example.com/post',
- repairMode: 'failed',
- rows: [buildPostImportRow ({
- sourceRow: 1,
- attributes: { title: 'old title', duration: '2' },
- importStatus: 'failed',
- recoverable: true,
- importErrors: { base: ['failed'] } })] })
- api.apiPost
- .mockResolvedValueOnce ({
- rows: [buildPostImportRow ({
- sourceRow: 1,
- attributes: { title: 'edited title', duration: '2' },
- importStatus: 'pending',
- recoverable: true })] })
- .mockResolvedValueOnce ({
- rows: [buildPostImportRow ({
- sourceRow: 1,
- attributes: { title: 'edited title', duration: '2' },
- importStatus: 'pending',
- recoverable: true })] })
- .mockResolvedValueOnce ({
- created: 0,
- skipped: 0,
- failed: 1,
- rows: [{
- sourceRow: 1,
- status: 'failed',
- recoverable: true,
- errors: { title: ['invalid'] } }] })
- dialogue.form.mockImplementationOnce (async options => {
- let actions: DialogueFormAction[] = []
- const controls: DialogueFormControls = {
- close: vi.fn (),
- confirm: vi.fn (),
- setActions: next => {
- actions = next
- } }
-
- render (options.body (controls))
- await waitFor (() => expect (actions.length).toBe (2))
- fireEvent.change (screen.getByDisplayValue ('old title'), {
- target: { value: 'edited title' } })
- await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
- })
-
- renderReviewPage ()
-
- fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
-
- await waitFor (() => {
- const saved = loadPostImportSession ()
- expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
- expect (saved?.rows[0]?.importStatus).toBe ('pending')
- })
-
- fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
-
- await waitFor (() => {
- expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.title)
- .toBe ('edited title')
- expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.duration)
- .toBe ('2')
- })
- })
-
- it ('retries only failed rows and does not resend created or skipped rows', async () => {
- savePostImportSession ({
- source: 'https://example.com/post',
- repairMode: 'failed',
- rows: [
- buildPostImportRow ({
- sourceRow: 1,
- importStatus: 'created',
- createdPostId: 1 }),
- buildPostImportRow ({
- sourceRow: 2,
- importStatus: 'skipped',
- skipReason: 'existing',
- existingPostId: 2 }),
- buildPostImportRow ({
- sourceRow: 3,
- importStatus: 'failed',
- recoverable: true,
- importErrors: { base: ['failed'] } })] })
- api.apiPost
- .mockResolvedValueOnce ({
- rows: [buildPostImportRow ({
- sourceRow: 3,
- importStatus: 'pending',
- recoverable: true })] })
- .mockResolvedValueOnce ({
- created: 0,
- skipped: 1,
- failed: 0,
- rows: [{
- sourceRow: 3,
- status: 'skipped',
- existingPostId: 9 }] })
-
- renderReviewPage ()
-
- fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
-
- await waitFor (() => {
- expect (api.apiPost.mock.calls[1]?.[1]?.rows).toEqual ([
- expect.objectContaining ({ sourceRow: 3 })])
- })
- })
-
- it ('allows ready rows to be manually skipped and excludes them from API payloads', async () => {
- savePostImportSession ({
- source: 'https://example.com/post',
- repairMode: 'all',
- rows: [buildPostImportRow ({ sourceRow: 1 })] })
-
- renderReviewPage ()
-
- fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' }))
-
- await waitFor (() => {
- expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
- })
- expect (api.apiPost).not.toHaveBeenCalled ()
- })
-
- it ('restores values and errors when manual skip is cleared', async () => {
- savePostImportSession ({
- source: 'https://example.com/post',
- repairMode: 'all',
- rows: [buildPostImportRow ({
- sourceRow: 1,
- status: 'error',
- attributes: { title: 'kept title' },
- validationErrors: { title: ['invalid'] } })] })
-
- renderReviewPage ()
-
- const toggle = await screen.findByRole ('checkbox', { name: 'スキップ' })
- fireEvent.click (toggle)
- fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' }))
-
- expect (screen.getByText ('kept title')).toBeInTheDocument ()
- expect (screen.getByText ('invalid')).toBeInTheDocument ()
- })
-
- it ('moves existing skipped rows into the collapsed section', async () => {
- savePostImportSession ({
- source: 'https://example.com/post',
- repairMode: 'all',
- rows: [
- buildPostImportRow ({
- sourceRow: 1,
- attributes: { title: 'existing row' },
- skipReason: 'existing',
- existingPostId: 2 }),
- buildPostImportRow ({
- sourceRow: 2,
- attributes: { title: 'normal row' } })] })
-
- renderReviewPage ()
-
- expect (screen.getByText ('既存投稿による自動スキップ 1件')).toBeInTheDocument ()
- expect (screen.queryByText ('existing row')).not.toBeInTheDocument ()
- expect (screen.getByText ('normal row')).toBeInTheDocument ()
-
- fireEvent.click (screen.getByRole ('button', { name: '既存投稿による自動スキップ 1件' }))
-
- expect (screen.getByText ('existing row')).toBeInTheDocument ()
- expect (screen.queryAllByRole ('button', { name: '編輯' })).toHaveLength (1)
- expect (screen.queryAllByRole ('checkbox', { name: 'スキップ' })).toHaveLength (1)
- })
-
- it ('navigates to /posts when the last failed row completes', async () => {
- savePostImportSession ({
- source: 'https://example.com/post',
- repairMode: 'failed',
- rows: [
- buildPostImportRow ({
- sourceRow: 1,
- importStatus: 'created',
- createdPostId: 1 }),
- buildPostImportRow ({
- sourceRow: 2,
- importStatus: 'failed',
- recoverable: true,
- importErrors: { base: ['failed'] } })] })
- api.apiPost
- .mockResolvedValueOnce ({
- rows: [buildPostImportRow ({
- sourceRow: 2,
- importStatus: 'pending',
- recoverable: true })] })
- .mockResolvedValueOnce ({
- created: 1,
- skipped: 0,
- failed: 0,
- rows: [{
- sourceRow: 2,
- status: 'created',
- post: { id: 2 } }] })
-
- renderReviewPage ()
-
- fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
-
- await waitFor (() => {
- expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
- })
- })
-
- it ('sorts recoverable pending validation rows to the top in repair mode', () => {
- savePostImportSession ({
- source: '',
- repairMode: 'failed',
- rows: [
- buildPostImportRow ({
- sourceRow: 2,
- attributes: { title: 'normal row' } }),
- buildPostImportRow ({
- sourceRow: 1,
- attributes: { title: 'repair row' },
- importStatus: 'pending',
- recoverable: true,
- validationErrors: { title: ['invalid'] } })] })
-
- const { container } = renderReviewPage ()
-
- const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map (
- node => node.textContent)
- expect (titles[0]).toBe ('repair row')
+ expect (api.apiPost).toHaveBeenCalledWith ('/posts/bulk', expect.any (FormData))
})
})
diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx
index 2d498db..9dcc181 100644
--- a/frontend/src/pages/posts/PostImportReviewPage.tsx
+++ b/frontend/src/pages/posts/PostImportReviewPage.tsx
@@ -169,16 +169,17 @@ const buildInitialRows = (urls: string[]): PostImportRow[] => {
urls.map ((url, index) => {
const sourceRow = index + 1
const rowIssues = issuesByRow[sourceRow] ?? []
+ const validationErrors: Record =
+ rowIssues.length > 0
+ ? { url: [...rowIssues] }
+ : { }
return {
sourceRow,
url,
attributes: emptyAttributes (),
fieldWarnings: { },
baseWarnings: [],
- validationErrors:
- rowIssues.length > 0
- ? { url: [...rowIssues] }
- : { },
+ validationErrors,
provenance: emptyProvenance (),
tagSources: emptyTagSources (),
status: rowIssues.length > 0 ? 'error' : 'pending',
@@ -481,7 +482,7 @@ const mergePreviewRow = (
nextRow.attributes.tags = preview.tags ?? ''
nextRow.displayTags = cloneDisplayTags (preview.displayTags)
}
- nextRow.tagSources.automatic = preview.tags ?? ''
+ nextRow.tagSources!.automatic = preview.tags ?? ''
if (currentRow.provenance.parentPostIds !== 'manual')
nextRow.attributes.parentPostIds = String (preview.parentPostIds ?? '')
if (currentRow.provenance.duration !== 'manual')
diff --git a/frontend/src/pages/posts/PostImportSourcePage.test.tsx b/frontend/src/pages/posts/PostImportSourcePage.test.tsx
index 2591401..c2d3687 100644
--- a/frontend/src/pages/posts/PostImportSourcePage.test.tsx
+++ b/frontend/src/pages/posts/PostImportSourcePage.test.tsx
@@ -1,21 +1,12 @@
-import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { fireEvent, screen } 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 }))
@@ -24,71 +15,53 @@ describe ('PostImportSourcePage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
- api.isApiError.mockReturnValue (false)
})
- it ('shows no empty error initially and validates only after Next is pressed', () => {
+ it ('validates an empty source only after Next is pressed', () => {
renderWithProviders ()
- expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
- expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument ()
+
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
+
expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument ()
- expect (api.apiPost).not.toHaveBeenCalled ()
+ expect (router.navigate).not.toHaveBeenCalled ()
})
- it ('shows frontend URL issues with line numbers without requesting preview', () => {
+ it ('reports frontend URL issues with original line numbers', () => {
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 ('navigates to /posts/new with query state after a successful preview', async () => {
- api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ()] })
+ it ('navigates with individually encoded URLs without calling an API', () => {
renderWithProviders ()
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
- target: { value: 'https://example.com/post' } })
+ target: {
+ value: 'https://example.com/one+a\nhttps://example.com/two?value=b+c' } })
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
- await waitFor (() => {
- expect (router.navigate).toHaveBeenCalledWith (
- expect.stringMatching (/^\/posts\/new\?/))
- })
- expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
- sessionStorage.key (index))).not.toContainEqual(
- expect.stringMatching (/^post-import-session:/))
+ expect (router.navigate).toHaveBeenCalledWith (
+ '/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
+ + '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc')
+ })
+
+ it ('disables Next when the encoded request target reaches 4096 bytes', () => {
+ renderWithProviders ()
+ const input = screen.getByRole ('textbox', { name: '' })
+
+ fireEvent.change (input, {
+ target: { value: `https://example.com/${ 'a'.repeat (4_100) }` } })
+
+ expect (screen.getByRole ('button', { name: '次へ' })).toBeDisabled ()
})
})
diff --git a/frontend/src/pages/posts/PostNewPage.test.tsx b/frontend/src/pages/posts/PostNewPage.test.tsx
index 215acb9..8d09d92 100644
--- a/frontend/src/pages/posts/PostNewPage.test.tsx
+++ b/frontend/src/pages/posts/PostNewPage.test.tsx
@@ -1,5 +1,4 @@
import { screen } from '@testing-library/react'
-import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostNewPage from '@/pages/posts/PostNewPage'
@@ -7,6 +6,7 @@ import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
+ apiGet: vi.fn (),
apiPost: vi.fn (),
isApiError: vi.fn () }))
@@ -16,27 +16,23 @@ describe ('PostNewPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
+ api.apiGet.mockResolvedValue ({
+ url: 'https://example.com/post',
+ title: 'post',
+ tags: '' })
sessionStorage.clear ()
})
it ('shows the source page on /posts/new', () => {
- renderWithProviders (
-
-
- }/>
-
- )
+ renderWithProviders (, {
+ route: '/posts/new' })
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
})
it ('shows the review page when query state is present', () => {
- renderWithProviders (
-
-
- }/>
-
- )
+ renderWithProviders (, {
+ route: '/posts/new?urls=https%3A%2F%2Fexample.com%2Fpost' })
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
diff --git a/frontend/src/pages/tags/NicoTagListPage.test.tsx b/frontend/src/pages/tags/NicoTagListPage.test.tsx
index 7d0d740..ae5eb17 100644
--- a/frontend/src/pages/tags/NicoTagListPage.test.tsx
+++ b/frontend/src/pages/tags/NicoTagListPage.test.tsx
@@ -6,7 +6,6 @@ import { dateString } from '@/lib/utils'
import { buildTag, buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
-import type { ReactNode } from 'react'
import type { NicoTag } from '@/types'
const api = vi.hoisted (() => ({
@@ -27,10 +26,10 @@ const scrollIntoView = vi.fn ()
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
-vi.mock ('@/components/dialogues/DialogueProvider', () => ({
- default: ({ children }: { children: ReactNode }) => <>{children}>,
- useDialogue: () => dialogue,
-}))
+vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
+ ...await importOriginal<
+ typeof import('@/components/dialogues/DialogueProvider')> (),
+ useDialogue: () => dialogue }))
const buildNicoTag = (values: Partial = {}): NicoTag => ({
...buildTag (),
diff --git a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx
index 2023a4e..1ff33a2 100644
--- a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx
+++ b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx
@@ -40,10 +40,10 @@ const postEmbed = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi)
-vi.mock ('@/components/dialogues/DialogueProvider', () => ({
- default: ({ children }: { children: ReactNode }) => <>{children}>,
- useDialogue: () => dialogue,
-}))
+vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
+ ...await importOriginal<
+ typeof import('@/components/dialogues/DialogueProvider')> (),
+ useDialogue: () => dialogue }))
vi.mock ('@/components/PostEmbed', () => ({
default: (props: {
ref?: { current: unknown }
diff --git a/frontend/src/test/postImportFactories.ts b/frontend/src/test/postImportFactories.ts
index 5c07e7a..3d83fd9 100644
--- a/frontend/src/test/postImportFactories.ts
+++ b/frontend/src/test/postImportFactories.ts
@@ -1,4 +1,4 @@
-import type { PostImportRow } from '@/lib/postImportSession'
+import type { PostImportRow } from '@/lib/postImportTypes'
export const buildPostImportRow = (
overrides: Partial = {},
diff --git a/frontend/src/test/render.tsx b/frontend/src/test/render.tsx
index ce8a1d9..5cae139 100644
--- a/frontend/src/test/render.tsx
+++ b/frontend/src/test/render.tsx
@@ -1,7 +1,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
+import { createContext, useContext, useState } from 'react'
import { HelmetProvider } from 'react-helmet-async'
-import { MemoryRouter } from 'react-router-dom'
+import { createMemoryRouter, RouterProvider } from 'react-router-dom'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
@@ -13,6 +14,20 @@ type Options = {
route?: string
}
+const TestContentContext = createContext (null)
+
+
+const TestRoute = () => {
+ const children = useContext (TestContentContext)
+
+ return (
+
+
+ {children}
+
+ )
+}
+
export const renderWithProviders = (
ui: ReactElement,
options: Options = {},
@@ -22,20 +37,23 @@ export const renderWithProviders = (
queries: { retry: false } },
})
- const Wrapper = ({ children }: { children: ReactNode }) => (
-
-
-
-
-
-
- {children}
-
-
-
-
-
- )
+ const Wrapper = ({ children }: { children: ReactNode }) => {
+ const [router] = useState (() => createMemoryRouter ([{
+ path: '*',
+ element: }], {
+ initialEntries: [options.route ?? '/'] }))
+
+ return (
+
+
+
+
+
+
+
+
+ )
+ }
return {
queryClient,