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 ( + <> + {location.pathname}{location.search} + + + ) +} + +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 {location.pathname}{location.search} +} + + +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,