コミットを比較

..

2 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 2f87669699 #399 2026-07-18 21:59:22 +09:00
みてるぞ 1c906d7432 #399 2026-07-18 21:19:53 +09:00
41個のファイルの変更1006行の追加1442行の削除
+18
ファイルの表示
@@ -732,6 +732,7 @@ class PostsController < ApplicationController
def post_incoming_snapshot title:, original_created_from:, original_created_before:, def post_incoming_snapshot title:, original_created_from:, original_created_before:,
tag_names:, video_ms_param:, duration_param:, parent_post_ids: 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, Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
with_sections: true) => with_sections: true) =>
{ tags:, sections: } { tags:, sections: }
@@ -769,6 +770,23 @@ class PostsController < ApplicationController
value.to_s value.to_s
end 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 def section_literal section
"[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]" "[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
end end
+15 -3
ファイルの表示
@@ -87,7 +87,7 @@ class PostCreatePlan
end end
raise Tag::SectionLiteralParseError.new(raw_name, raw_name) if name.include?('[') || name.include?(']') raise Tag::SectionLiteralParseError.new(raw_name, raw_name) if name.include?('[') || name.include?(']')
[TagName.canonicalise(name).first, category&.to_sym, sections] [resolved_tag_name(name), category&.to_sym, sections]
end end
def build_default_tag_specs direct_tag_specs def build_default_tag_specs direct_tag_specs
@@ -201,7 +201,7 @@ class PostCreatePlan
def serialised_tags direct_tag_specs, tag_sections def serialised_tags direct_tag_specs, tag_sections
direct_tag_specs.map { |spec| 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(' ') }.sort.join(' ')
end end
@@ -210,10 +210,15 @@ class PostCreatePlan
{ {
name: spec[:name], name: spec[:name],
category: spec[:category].to_s, 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] } }.sort_by { _1[:name] }
end 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 def normalise_video_ms snapshot_tag_specs
return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME } return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME }
@@ -254,4 +259,11 @@ class PostCreatePlan
end end
end end
end end
def resolved_tag_name name
tag_name = TagName.includes(:canonical).find_by(name:)
return name if tag_name.nil?
(tag_name.canonical || tag_name).name
end
end end
+1 -7
ファイルの表示
@@ -126,7 +126,6 @@ class PostImportPreviewer
validate_basic_data(attributes, validation_errors) validate_basic_data(attributes, validation_errors)
validate_preview_tags(merged_tags(tag_sources, provenance['tags']), validate_preview_tags(merged_tags(tag_sources, provenance['tags']),
validation_errors, validation_errors,
field_warnings,
known_tags) known_tags)
validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids) validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids)
attributes.delete('url') attributes.delete('url')
@@ -450,7 +449,7 @@ class PostImportPreviewer
[] []
end end
def validate_preview_tags raw, errors, field_warnings, known_tags def validate_preview_tags raw, errors, known_tags
names = raw.to_s.split names = raw.to_s.split
return if names.empty? return if names.empty?
if names.any? { _1.downcase.start_with?('nico:') } if names.any? { _1.downcase.start_with?('nico:') }
@@ -462,11 +461,6 @@ class PostImportPreviewer
existing = parsed.filter_map { known_tags[_1] } existing = parsed.filter_map { known_tags[_1] }
deprecated = existing.select(&:deprecated?).map(&:name) deprecated = existing.select(&:deprecated?).map(&:name)
errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present? errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present?
known = existing.reject(&:deprecated?).map(&:name)
new_tags = parsed.uniq - known
if new_tags.present?
add_field_warning!(field_warnings, 'tags', "新規タグを作成します: #{ new_tags.join(' ') }")
end
rescue Tag::SectionLiteralParseError rescue Tag::SectionLiteralParseError
errors[:tags] = ['タグ区間の記法が不正です.'] errors[:tags] = ['タグ区間の記法が不正です.']
end end
+11 -11
ファイルの表示
@@ -110,7 +110,7 @@ RSpec.describe Post, type: :model do
end end
describe '.resized_thumbnail_attachment' do 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( blob = image_blob(
width: 360, width: 360,
height: 180, height: 180,
@@ -123,13 +123,13 @@ RSpec.describe Post, type: :model do
resized = described_class.resized_thumbnail_attachment(upload_for(blob)) resized = described_class.resized_thumbnail_attachment(upload_for(blob))
image = read_image(resized) image = read_image(resized)
expect(image.dimensions).to eq([180, 180]) expect(image.dimensions).to eq([180, 90])
expect_green(colour_at(image, 0, 90)) expect_red(colour_at(image, 0, 45))
expect_green(colour_at(image, 90, 90)) expect_green(colour_at(image, 90, 45))
expect_green(colour_at(image, 179, 90)) expect_red(colour_at(image, 179, 45))
end 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( blob = image_blob(
width: 180, width: 180,
height: 360, height: 360,
@@ -142,10 +142,10 @@ RSpec.describe Post, type: :model do
resized = described_class.resized_thumbnail_attachment(upload_for(blob)) resized = described_class.resized_thumbnail_attachment(upload_for(blob))
image = read_image(resized) image = read_image(resized)
expect(image.dimensions).to eq([180, 180]) expect(image.dimensions).to eq([90, 180])
expect_green(colour_at(image, 90, 0)) expect_red(colour_at(image, 45, 0))
expect_green(colour_at(image, 90, 90)) expect_green(colour_at(image, 45, 90))
expect_green(colour_at(image, 90, 179)) expect_red(colour_at(image, 45, 179))
end end
it 'keeps a square image square without distortion' do 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 expect(post.thumbnail).to be_attached
image = read_image(post.thumbnail) image = read_image(post.thumbnail)
expect(image.dimensions).to eq([180, 180]) expect(image.dimensions).to eq([180, 135])
end end
it 'does not attach anything when thumbnail conversion fails' do it 'does not attach anything when thumbnail conversion fails' do
-184
ファイルの表示
@@ -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
+158 -10
ファイルの表示
@@ -9,11 +9,11 @@ RSpec.describe 'Posts API', type: :request do
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。 # resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
before do before do
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true) allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
allow(Post).to receive(:resized_thumbnail_attachment).and_return( allow(Post).to receive(:resized_thumbnail_attachment) do
io: StringIO.new('dummy'), { io: StringIO.new('dummy'),
filename: 'resized_thumbnail.jpg', filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg' content_type: 'image/jpeg' }
) end
end end
def create_nico_tag!(name) def create_nico_tag!(name)
@@ -21,8 +21,7 @@ RSpec.describe 'Posts API', type: :request do
end end
def dummy_upload def dummy_upload
# 中身は何でもいい(加工処理はスタブしてる) real_thumbnail_upload
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
end end
def real_thumbnail_upload def real_thumbnail_upload
@@ -693,6 +692,73 @@ RSpec.describe 'Posts API', type: :request do
end end
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 describe 'POST /posts' do
let(:member) { create(:user, :member) } let(:member) { create(:user, :member) }
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) } 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) expect(response).to have_http_status(:forbidden)
end 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('<svg></svg>'),
'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 it '201 and creates post + tags when member' do
sign_in_as(member) sign_in_as(member)
@@ -769,7 +875,8 @@ RSpec.describe 'Posts API', type: :request do
) )
expect(response).to have_http_status(:created) 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 end
it 'returns 422 and does not create a post when thumbnail resize fails' do 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(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include( expect(json.fetch('errors')).to include(
'tags' => ['廃止済みタグは付与できません.'] 'tags' => ['廃止済みタグがあります: deprecated_direct_tag']
) )
end end
@@ -1251,6 +1358,47 @@ RSpec.describe 'Posts API', type: :request do
end end
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 describe 'PUT /posts/:id' do
let(:member) { create(:user, :member) } let(:member) { create(:user, :member) }
@@ -2271,9 +2419,9 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
expect(Time.iso8601(json.fetch('original_created_from'))) 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'))) 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 end
it 'rejects unparseable original created timestamps on PUT /posts/:id' do it 'rejects unparseable original created timestamps on PUT /posts/:id' do
+59
ファイルの表示
@@ -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
+82
ファイルの表示
@@ -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
+12 -16
ファイルの表示
@@ -20,12 +20,12 @@ RSpec.describe PostCreator do
allow(PostVersionRecorder).to receive(:record!) allow(PostVersionRecorder).to receive(:record!)
end end
it 'prefers an explicit upload over thumbnail_base' do it 'prefers thumbnail_base over an explicit upload' do
allow(Post).to receive(:resized_thumbnail_attachment).and_return( expect(Post).not_to receive(:resized_thumbnail_attachment)
io: StringIO.new('upload'), allow(Post).to receive(:remote_thumbnail_attachment).and_return(
io: StringIO.new('remote'),
filename: 'resized_thumbnail.jpg', filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg') content_type: 'image/jpeg')
expect_any_instance_of(Post).not_to receive(:attach_thumbnail_from_url!)
post = described_class.new( post = described_class.new(
actor:, actor:,
@@ -41,13 +41,12 @@ RSpec.describe PostCreator do
end end
it 'uses the common remote thumbnail attach path when thumbnail_base is given' do 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!) expect(Post).to receive(:remote_thumbnail_attachment)
.with('https://example.com/thumb.jpg') do |post, _url| .with('https://example.com/thumb.jpg')
post.thumbnail.attach( .and_return(
io: StringIO.new('thumbnail'), io: StringIO.new('thumbnail'),
filename: 'thumbnail.jpg', filename: 'thumbnail.jpg',
content_type: 'image/jpeg') content_type: 'image/jpeg')
end
post = described_class.new( post = described_class.new(
actor:, actor:,
@@ -61,8 +60,8 @@ RSpec.describe PostCreator do
expect(post.thumbnail).to be_attached expect(post.thumbnail).to be_attached
end end
it 'keeps creating the post and records a warning when remote thumbnail fetch fails' do it 'does not create a post when remote thumbnail fetch fails' do
allow_any_instance_of(Post).to receive(:attach_thumbnail_from_url!) allow(Post).to receive(:remote_thumbnail_attachment)
.and_raise(Post::RemoteThumbnailFetchFailed, 'サムネール画像を取得できませんでした.') .and_raise(Post::RemoteThumbnailFetchFailed, 'サムネール画像を取得できませんでした.')
creator = described_class.new( creator = described_class.new(
actor:, actor:,
@@ -72,11 +71,8 @@ RSpec.describe PostCreator do
thumbnail_base: 'https://example.com/thumb.jpg', thumbnail_base: 'https://example.com/thumb.jpg',
tags: '' }) tags: '' })
post = creator.create! post_count = Post.count
expect { creator.create! }.to raise_error(Post::RemoteThumbnailFetchFailed)
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg') expect(Post.count).to eq(post_count)
expect(post.thumbnail).not_to be_attached
expect(creator.field_warnings).to eq(
thumbnail_base: ['サムネール画像を取得できませんでした.'])
end end
end end
-77
ファイルの表示
@@ -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
-152
ファイルの表示
@@ -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
-40
ファイルの表示
@@ -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
+52
ファイルの表示
@@ -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(
'<svg width="10" height="10"></svg>',
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
+11 -10
ファイルの表示
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe Preview::ThumbnailFetcher do RSpec.describe Preview::ThumbnailFetcher do
describe '.fetch' 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( page = Preview::HttpFetcher::Response.new(
'<meta property="og:image" content="https://example.com/thumb.svg">', '<meta property="og:image" content="https://example.com/thumb.svg">',
'text/html', 'text/html',
@@ -12,8 +12,9 @@ RSpec.describe Preview::ThumbnailFetcher do
'image/svg+xml', 'image/svg+xml',
'https://example.com/thumb.svg') 'https://example.com/thumb.svg')
allow(Preview::UrlSafety).to receive(:validate) allow(Preview::UrlSafety).to receive(:validate) do |url|
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']]) [URI.parse(url), ['203.0.113.10']]
end
allow(Preview::HttpFetcher).to receive(:fetch) allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES) .with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
.and_return(page) .and_return(page)
@@ -21,9 +22,7 @@ RSpec.describe Preview::ThumbnailFetcher do
.with('https://example.com/thumb.svg') .with('https://example.com/thumb.svg')
.and_return(svg) .and_return(svg)
expect { expect(described_class.fetch('https://example.com/page')).to eq('<svg></svg>')
described_class.fetch('https://example.com/page')
}.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
end end
it 'accepts allowed image content type with parameters' do it 'accepts allowed image content type with parameters' do
@@ -32,12 +31,13 @@ RSpec.describe Preview::ThumbnailFetcher do
'text/html', 'text/html',
'https://example.com/page') 'https://example.com/page')
image = Preview::HttpFetcher::Response.new( image = Preview::HttpFetcher::Response.new(
'jpeg-bytes', "\xFF\xD8\xFFjpeg-bytes".b,
'image/jpeg; charset=binary', 'image/jpeg; charset=binary',
'https://example.com/thumb.jpg') 'https://example.com/thumb.jpg')
allow(Preview::UrlSafety).to receive(:validate) allow(Preview::UrlSafety).to receive(:validate) do |url|
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']]) [URI.parse(url), ['203.0.113.10']]
end
allow(Preview::HttpFetcher).to receive(:fetch) allow(Preview::HttpFetcher).to receive(:fetch)
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES) .with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
.and_return(page) .and_return(page)
@@ -45,7 +45,8 @@ RSpec.describe Preview::ThumbnailFetcher do
.with('https://example.com/thumb.jpg') .with('https://example.com/thumb.jpg')
.and_return(image) .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
end end
+13 -11
ファイルの表示
@@ -73,11 +73,11 @@ RSpec.describe "nico:sync" do
Tag.tagme Tag.tagme
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }]) stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
allow(URI).to receive(:open) allow(URI).to receive(:open) do
.and_return(
StringIO.new( StringIO.new(
'<meta name="thumbnail" content="https://example.com/thumb.jpg">')) '<meta name="thumbnail" content="https://example.com/thumb.jpg">')
expect(post).to receive(:attach_thumbnail_from_url!) end
expect_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
.with('https://example.com/thumb.jpg') .with('https://example.com/thumb.jpg')
run_rake_task('nico:sync') run_rake_task('nico:sync')
@@ -92,18 +92,20 @@ RSpec.describe "nico:sync" do
Tag.tagme Tag.tagme
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }]) stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
allow(URI).to receive(:open) allow(URI).to receive(:open) do
.and_return(
StringIO.new( StringIO.new(
'<meta name="thumbnail" content="https://example.com/thumb.jpg">')) '<meta name="thumbnail" content="https://example.com/thumb.jpg">')
expect(post).to receive(:attach_thumbnail_from_url!) end
.with('https://example.com/thumb.jpg') calls = 0
.twice allow_any_instance_of(Post).to receive(:attach_thumbnail_from_url!) do
.and_raise(Post::RemoteThumbnailFetchFailed, 'failed') calls += 1
raise Post::RemoteThumbnailFetchFailed, 'failed'
end
2.times do 2.times do
run_rake_task('nico:sync') run_rake_task('nico:sync')
end end
expect(calls).to eq(2)
end end
it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do
+5 -3
ファイルの表示
@@ -78,16 +78,18 @@ describe ('PostEditForm', () => {
render (<PostEditForm post={post} onSave={vi.fn ()}/>) render (<PostEditForm post={post} onSave={vi.fn ()}/>)
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] const tags = screen.getAllByRole ('textbox')[2]
fireEvent.change (tags, { target: { value: 'general-tag' } }) 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, { fireEvent.change (tags, {
target: { value: '動画 general-tag' }, 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 () => { it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
+1 -1
ファイルの表示
@@ -73,7 +73,7 @@ describe ('PostOriginalCreatedTimeField', () => {
setOriginalCreatedBefore={vi.fn ()}/>, 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' } }) fireEvent.change (input, { target: { value: '2024-01-01T12:35' } })
expect (setFrom).toHaveBeenCalledWith ('2024-01-01T03:35Z') expect (setFrom).toHaveBeenCalledWith ('2024-01-01T03:35Z')
+2 -4
ファイルの表示
@@ -17,21 +17,19 @@ describe ('menuOutline', () => {
for (const role of ['member', 'admin'] as const) 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, '素材', '追加')?.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 (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', '素材', '追加')?.visible).toBe (false) expect (submenuItem ('guest', '素材', '追加')?.visible).toBe (false)
expect (submenuItem ('guest', 'Wiki', '新規')?.visible).toBe (false) expect (submenuItem ('guest', 'Wiki', '新規')?.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', () => { it ('uses /posts/new as the post creation entrypoint', () => {
expect (submenuItem ('member', '広場', '取込')?.to).toBe ('/posts/new') expect (submenuItem ('member', '広場', '追加')?.to).toBe ('/posts/new')
}) })
it ('keeps material suppression admin-only', () => { it ('keeps material suppression admin-only', () => {
+3 -1
ファイルの表示
@@ -80,7 +80,9 @@ describe ('DialogueProvider', () => {
expect (dialogue).toHaveClass ('max-h-[calc(100dvh-1rem)]', 'flex-col', 'max-w-3xl') expect (dialogue).toHaveClass ('max-h-[calc(100dvh-1rem)]', 'flex-col', 'max-w-3xl')
await waitFor (() => expect (screen.getByRole ('button', { name: '左操作' })) await waitFor (() => expect (screen.getByRole ('button', { name: '左操作' }))
.toBeInTheDocument ()) .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: '左操作' })) fireEvent.click (screen.getByRole ('button', { name: '左操作' }))
await waitFor (() => expect (action).toHaveBeenCalledTimes (1)) await waitFor (() => expect (action).toHaveBeenCalledTimes (1))
-10
ファイルの表示
@@ -2,8 +2,6 @@ import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { buildPostImportRow } from '@/test/postImportFactories' import { buildPostImportRow } from '@/test/postImportFactories'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue' import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
@@ -14,14 +12,6 @@ vi.mock ('@/components/posts/PostCreationDataFields', () => ({
})) }))
describe ('PostCreationDataFields usage', () => { describe ('PostCreationDataFields usage', () => {
it ('is used by PostNewPage', async () => {
const { default: PostNewPage } = await import ('@/pages/posts/PostNewPage')
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
})
it ('is used by PostImportRowForm', async () => { it ('is used by PostImportRowForm', async () => {
const { default: PostImportRowForm } = await import ( const { default: PostImportRowForm } = await import (
'@/components/posts/import/PostImportRowForm') '@/components/posts/import/PostImportRowForm')
+4 -2
ファイルの表示
@@ -135,7 +135,7 @@ describe ('PostImportRowForm', () => {
}) })
it ( 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 () => { async () => {
let actions: DialogueFormAction[] = [] let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = { 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.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument () expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
+2 -1
ファイルの表示
@@ -12,7 +12,7 @@ const PostImportTagLinks: FC<Props> = ({ tags }) => {
return null return null
return ( return (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap text-xs gap-x-1">
{tags.map (tag => { {tags.map (tag => {
const key = `${ tag.category }:${ tag.name }:${ tag.sectionLiterals?.join ('|') ?? '' }` const key = `${ tag.category }:${ tag.name }:${ tag.sectionLiterals?.join ('|') ?? '' }`
return ( return (
@@ -21,6 +21,7 @@ const PostImportTagLinks: FC<Props> = ({ tags }) => {
tag={{ tag={{
name: tag.name, name: tag.name,
category: tag.category }} category: tag.category }}
linkFlg={false}
withWiki={false} withWiki={false}
withCount={false}/> withCount={false}/>
{tag.sectionLiterals?.map (literal => ( {tag.sectionLiterals?.map (literal => (
+22 -47
ファイルの表示
@@ -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 { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview' import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('PostImportThumbnailPreview', () => { describe ('PostImportThumbnailPreview', () => {
beforeEach (() => { beforeEach (() => {
vi.clearAllMocks () vi.clearAllMocks ()
@@ -16,69 +10,50 @@ describe ('PostImportThumbnailPreview', () => {
globalThis.URL.revokeObjectURL = vi.fn () globalThis.URL.revokeObjectURL = vi.fn ()
}) })
it ('uses a backend-fetched blob URL instead of the external thumbnail URL directly', async () => { it ('renders the remote URL directly without a backend proxy', () => {
api.apiGet.mockResolvedValueOnce (new Blob (['img'], { type: 'image/png' }))
render ( render (
<PostImportThumbnailPreview <PostImportThumbnailPreview
url="https://example.com/thumbnail.jpg" url="https://example.com/thumbnail.jpg"
className="h-10 w-10"/>) className="h-10 w-10"/>)
await waitFor (() => { expect (screen.getByRole ('img')).toHaveAttribute (
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
})
expect (screen.getByRole ('img')).not.toHaveAttribute (
'src', 'src',
'https://example.com/thumbnail.jpg') 'https://example.com/thumbnail.jpg')
expect (api.apiGet).toHaveBeenCalledWith ('/preview/thumbnail', { expect (screen.getByRole ('img')).toHaveAttribute (
params: { url: 'https://example.com/thumbnail.jpg' }, 'referrerpolicy',
responseType: 'blob' }) 'no-referrer')
}) })
it ('does not render the unsafe URL directly when preview fetching fails', async () => { it ('shows the empty frame after the remote image fails', () => {
api.apiGet.mockRejectedValueOnce (new Error ('unsafe'))
const { container } = render ( const { container } = render (
<PostImportThumbnailPreview <PostImportThumbnailPreview
url="http://127.0.0.1/private.png" url="https://example.com/missing.jpg"
className="h-10 w-10"/>) className="h-10 w-10"/>)
await waitFor (() => { fireEvent.error (screen.getByRole ('img'))
expect (screen.queryByRole ('img')).toBeNull () expect (screen.queryByRole ('img')).toBeNull ()
})
expect (screen.queryByText ('サムネールを表示できません')).toBeNull ()
expect (screen.queryByText ('なし')).toBeNull ()
expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull () expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull ()
expect (container.textContent).toBe ('') expect (container.textContent).toBe ('')
expect (screen.queryByRole ('img')).toBeNull ()
}) })
it ('revokes the old object URL when the source URL changes', async () => { it ('uses and revokes an object URL only when the remote URL is blank', () => {
const createObjectUrlMock = const file = new File (['image'], 'thumbnail.png', { type: 'image/png' })
globalThis.URL.createObjectURL as unknown as ReturnType<typeof vi.fn> const { rerender, unmount } = render (
createObjectUrlMock <PostImportThumbnailPreview url="" file={file} className="h-10 w-10"/>)
.mockReturnValueOnce ('blob:first')
.mockReturnValueOnce ('blob:second')
api.apiGet
.mockResolvedValueOnce (new Blob (['first'], { type: 'image/png' }))
.mockResolvedValueOnce (new Blob (['second'], { type: 'image/png' }))
const { rerender } = render ( expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
<PostImportThumbnailPreview
url="https://example.com/first.jpg"
className="h-10 w-10"/>)
await waitFor (() => {
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:first')
})
rerender ( rerender (
<PostImportThumbnailPreview <PostImportThumbnailPreview
url="https://example.com/second.jpg" url="https://example.com/remote.jpg"
file={file}
className="h-10 w-10"/>) className="h-10 w-10"/>)
expect (screen.getByRole ('img')).toHaveAttribute (
'src',
'https://example.com/remote.jpg')
await waitFor (() => { unmount ()
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:second') expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:preview')
})
expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:first')
}) })
}) })
+4 -4
ファイルの表示
@@ -16,14 +16,14 @@ describe ('displayPostImportStatus', () => {
skipReason: 'manual' }))).toBe ('skipped') 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 ({ expect (displayPostImportStatus (buildPostImportRow ({
status: 'error', status: 'error',
validationErrors: { title: ['invalid'] } }))).toBeNull () validationErrors: { title: ['invalid'] } }))).toBe ('error')
expect (displayPostImportStatus (buildPostImportRow ({ expect (displayPostImportStatus (buildPostImportRow ({
importStatus: 'failed' }))).toBeNull () importStatus: 'failed' }))).toBe ('failed')
expect (displayPostImportStatus (buildPostImportRow ({ expect (displayPostImportStatus (buildPostImportRow ({
importStatus: 'created', importStatus: 'created',
createdPostId: 3 }))).toBeNull () createdPostId: 3 }))).toBe ('created')
}) })
}) })
+2 -1
ファイルの表示
@@ -16,7 +16,7 @@ import { creatableImportRows,
resultRowMessages, resultRowMessages,
resultSummaryCounts, resultSummaryCounts,
retryImportRow, retryImportRow,
reviewSummaryCounts } from '@/lib/postImportSession' reviewSummaryCounts } from '@/lib/postImportRows'
import { buildPostImportRow } from '@/test/postImportFactories' import { buildPostImportRow } from '@/test/postImportFactories'
describe ('post import row state', () => { describe ('post import row state', () => {
@@ -274,6 +274,7 @@ describe ('post import row state', () => {
thumbnailBase: '', thumbnailBase: '',
originalCreatedFrom: '', originalCreatedFrom: '',
originalCreatedBefore: '', originalCreatedBefore: '',
duration: '2',
tags: 'edited-tag', tags: 'edited-tag',
parentPostIds: '' }, parentPostIds: '' },
true) true)
+4 -1
ファイルの表示
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { countImportSourceLines, validateImportSource } from '@/lib/postImportSession' import {
countImportSourceLines,
validateImportSource,
} from '@/lib/postImportSourceValidation'
describe ('post import source validation', () => { describe ('post import source validation', () => {
it ('counts trimmed non-empty CRLF and LF rows', () => { it ('counts trimmed non-empty CRLF and LF rows', () => {
+13 -106
ファイルの表示
@@ -1,124 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanupExpiredPostImportSessions, import {
clearPostImportSourceDraft, clearPostImportSourceDraft,
loadPostImportSession,
loadPostImportSourceDraft, loadPostImportSourceDraft,
savePostImportSession, savePostImportSourceDraft,
savePostImportSourceDraft } from '@/lib/postImportSession' } from '@/lib/postImportStorage'
import { buildPostImportRow } from '@/test/postImportFactories'
describe ('post import storage', () => { describe ('post import source draft storage', () => {
beforeEach (() => { beforeEach (() => {
sessionStorage.clear () sessionStorage.clear ()
vi.useRealTimers () vi.restoreAllMocks ()
}) })
it ('round-trips a valid session and source draft', () => { it ('round-trips and clears the URL list 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' }] })
expect (savePostImportSourceDraft ('https://example.com')).toBe (true) expect (savePostImportSourceDraft ('https://example.com')).toBe (true)
expect (loadPostImportSourceDraft ()).toEqual ({ source: 'https://example.com' }) expect (loadPostImportSourceDraft ()).toEqual ({
source: 'https://example.com' })
clearPostImportSourceDraft () clearPostImportSourceDraft ()
expect (loadPostImportSourceDraft ()).toEqual ({ source: '' }) expect (loadPostImportSourceDraft ()).toEqual ({ source: '' })
}) })
it ('rejects inconsistent post IDs and terminal statuses', () => { it ('ignores malformed stored drafts', () => {
const session = { sessionStorage.setItem ('post-import-source-draft', '{')
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 }]
for (const row of invalidRows) expect (loadPostImportSourceDraft ()).toEqual ({ source: '' })
{
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 ()
}) })
it ('reports storage access failures without throwing', () => { it ('reports storage access failures without throwing', () => {
+43
ファイルの表示
@@ -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)
})
})
+1 -1
ファイルの表示
@@ -1,5 +1,5 @@
const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls=' const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls='
const MAX_POST_NEW_REVIEW_TARGET_BYTES = 4096 const MAX_POST_NEW_REVIEW_TARGET_BYTES = 4_096
const textEncoder = new TextEncoder () const textEncoder = new TextEncoder ()
+77
ファイルの表示
@@ -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 (
<>
<output aria-label="location">{location.pathname}{location.search}</output>
<button type="button" onClick={() => navigate ('/next?tab=one')}>move</button>
<button
type="button"
onClick={() => {
allowNextNavigation ()
navigate ('/allowed')
}}>
allowed move
</button>
</>)
}
describe ('useUnsavedChangesGuard', () => {
it ('blocks route changes and resets a cancelled transition', async () => {
renderWithProviders (<GuardHarness/>, { 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 (<GuardHarness/>, { 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 (<GuardHarness/>, { 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')
})
})
+33 -29
ファイルの表示
@@ -38,6 +38,7 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
const bypassNextNavigationRef = useRef<symbol | null> (null) const bypassNextNavigationRef = useRef<symbol | null> (null)
const [revision, setRevision] = useState (0) const [revision, setRevision] = useState (0)
const dialogueOpenRef = useRef (false) const dialogueOpenRef = useRef (false)
const handlingBlockedTransitionRef = useRef (false)
const registerUnsavedChangesSource = useCallback (( const registerUnsavedChangesSource = useCallback ((
source: UnsavedChangesSource, source: UnsavedChangesSource,
@@ -73,6 +74,8 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
[sources], [sources],
) )
const hasUnsavedChanges = dirtySources.length > 0 const hasUnsavedChanges = dirtySources.length > 0
const hasUnsavedChangesRef = useRef (hasUnsavedChanges)
hasUnsavedChangesRef.current = hasUnsavedChanges
const shouldBlock = useCallback (() => { const shouldBlock = useCallback (() => {
if (bypassNextNavigationRef.current != null) if (bypassNextNavigationRef.current != null)
{ {
@@ -80,11 +83,11 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
return false return false
} }
return hasUnsavedChanges return hasUnsavedChangesRef.current
}, [hasUnsavedChanges]) }, [])
const blocker = useBlocker (shouldBlock) const blocker = useBlocker (shouldBlock)
const discardDirtyChanges = useCallback (async (): Promise<boolean> => { const confirmDiscardChanges = useCallback (async (): Promise<boolean> => {
if (!(hasUnsavedChanges)) if (!(hasUnsavedChanges))
return true return true
@@ -102,48 +105,49 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
if (!(confirmed)) if (!(confirmed))
return false return false
try
{
for (const source of dirtySources)
await source.discard ()
}
catch
{
return false
}
return true return true
} }
finally finally
{ {
dialogueOpenRef.current = false dialogueOpenRef.current = false
} }
}, [dialogue, dirtySources, hasUnsavedChanges]) }, [dialogue, hasUnsavedChanges])
useEffect (() => { useEffect (() => {
if (blocker.state !== 'blocked') if (blocker.state !== 'blocked' || handlingBlockedTransitionRef.current)
return return
let active = true handlingBlockedTransitionRef.current = true
void (async () => { void (async () => {
const confirmed = await discardDirtyChanges () try
if (!(active))
return
if (confirmed)
{ {
blocker.proceed () const confirmed = await confirmDiscardChanges ()
if (!(confirmed))
{
blocker.reset ()
return return
} }
blocker.reset () let discardResults: Array<void | Promise<void>>
}) () try
{
return () => { discardResults = dirtySources.map (source => source.discard ())
active = false
} }
}, [blocker, discardDirtyChanges]) catch
{
blocker.reset ()
return
}
blocker.proceed ()
await Promise.allSettled (discardResults)
}
finally
{
handlingBlockedTransitionRef.current = false
}
}) ()
}, [blocker, confirmDiscardChanges, dirtySources])
useEffect (() => { useEffect (() => {
if (!(hasUnsavedChanges)) if (!(hasUnsavedChanges))
-48
ファイルの表示
@@ -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 (
<MemoryRouter initialEntries={['/posts/import']}>
<Routes>
<Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
it ('redirects legacy review routes to /posts/new', () => {
render (
<MemoryRouter initialEntries={['/posts/import/legacy/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
it ('redirects legacy result routes to /posts', () => {
render (
<MemoryRouter initialEntries={['/posts/import/legacy/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<Navigate to="/posts" replace/>}/>
<Route
path="/posts/new/:sessionId/result"
element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<div>POSTS ROUTE</div>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
})
+116 -407
ファイルの表示
@@ -1,448 +1,157 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async' import { useLocation } from 'react-router-dom'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { loadPostImportSession, savePostImportSession } from '@/lib/postImportSession'
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage' import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import { buildUser } from '@/test/factories' 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 { ReactNode } from 'react'
import type { PostImportRow } from '@/lib/postImportSession'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiGet: vi.fn (), apiGet: vi.fn (),
apiPost: vi.fn (), apiPost: vi.fn (),
})) isApiError: vi.fn (() => false) }))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
const dialogue = vi.hoisted (() => ({
form: vi.fn (() => Promise.resolve ()),
}))
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('framer-motion', () => ({
vi.mock ('@/lib/dialogues/useDialogue', () => ({ AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}</>,
default: () => dialogue, motion: {
})) div: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
main: ({ children }: { children?: ReactNode }) => <main>{children}</main> } }))
const renderReviewPage = (route = '/posts/new/review') => const metadata = (url: string, title: string) => ({
render ( url,
<HelmetProvider> title,
<MemoryRouter initialEntries={[route]}> thumbnailBase: 'https://example.com/thumbnail.jpg',
<Routes> originalCreatedFrom: '',
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/> originalCreatedBefore: '',
<Route duration: '',
path="/posts/new/review" tags: '',
element={<PostImportReviewPage user={buildUser ()}/>}/> parentPostIds: [],
<Route path="/posts" element={<div>POSTS ROUTE</div>}/> fieldWarnings: { },
</Routes> baseWarnings: [],
</MemoryRouter> displayTags: [] })
</HelmetProvider>)
const LocationProbe = () => {
const location = useLocation ()
return <output aria-label="current-location">{location.pathname}{location.search}</output>
}
const renderReviewPage = (urls: string[]) => {
const search = urls.map (url => encodeURIComponent (url)).join ('+')
return renderWithProviders (
<>
<PostImportReviewPage user={buildUser ()}/>
<LocationProbe/>
</>,
{ route: `/posts/new?urls=${ search }` })
}
describe ('PostImportReviewPage', () => { describe ('PostImportReviewPage', () => {
beforeEach (() => { beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks () vi.clearAllMocks ()
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview') api.isApiError.mockReturnValue (false)
globalThis.URL.revokeObjectURL = vi.fn ()
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
}) })
it ('redirects to /posts/new when no current work exists', async () => { it ('fetches metadata with at most four concurrent requests', async () => {
renderReviewPage () const resolvers: Array<(value: ReturnType<typeof metadata>) => void> = []
api.apiGet.mockImplementation ((_path, options) =>
await waitFor (() => { new Promise (resolve => {
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument () resolvers.push (resolve)
}) const url = String (options.params.url)
}) void url
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 ({ const urls = Array.from (
created: 0, { length: 6 },
skipped: 0, (_, index) => `https://example.com/${ index + 1 }`)
failed: 0,
rows: [] })
renderReviewPage () renderReviewPage (urls)
fireEvent.click (screen.getByRole ('button', { name: '取込実行' })) 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 (() => { await waitFor (() => {
expect (screen.getByRole ('button', { name: '編輯' })).toBeDisabled () expect (screen.getAllByText ('登録不可')).toHaveLength (2)
expect (screen.getByRole ('button', { name: 'URL リスト入力へ戻る' })).toBeDisabled () })
expect (screen.getByRole ('button', { name: '取込実行' })).toBeDisabled () expect (screen.getAllByRole ('button', { name: '編輯' })[0]).toBeEnabled ()
}) })
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] }) 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 ('does not call import when validation omits a requested source row', async () => { it ('keeps a recoverable bulk failure on the review screen', async () => {
savePostImportSession ({ api.apiGet.mockResolvedValue (
source: 'https://example.com/post-1\nhttps://example.com/post-2', metadata ('https://example.com/post', 'new post'))
repairMode: 'all', api.apiPost.mockResolvedValue ({
rows: [ results: [{
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', status: 'failed',
recoverable: true, recoverable: true,
errors: { title: ['invalid'] } }] }) errors: { title: ['invalid title'] } }] })
renderReviewPage () renderReviewPage (['https://example.com/post'])
fireEvent.click (screen.getByRole ('button', { name: '取込実行' })) fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
expect (await screen.findByText ('invalid')).toBeInTheDocument () expect (await screen.findAllByText ('登録失敗')).toHaveLength (2)
expect (screen.queryByText ('POSTS ROUTE')).not.toBeInTheDocument () 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 ('keeps edited recoverable rows in session before the next batch submit', async () => { it ('navigates to /posts after every submitted row is created', async () => {
savePostImportSession ({ api.apiGet.mockResolvedValue (
source: 'https://example.com/post', metadata ('https://example.com/post', 'new post'))
repairMode: 'failed', api.apiPost.mockResolvedValue ({
rows: [buildPostImportRow ({ results: [{ status: 'created', post: { id: 42 } }] })
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)) renderReviewPage (['https://example.com/post'])
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 (await screen.findByRole ('button', { name: '追加' }))
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
await waitFor (() => { await waitFor (() => {
const saved = loadPostImportSession () expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts')
expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
expect (saved?.rows[0]?.importStatus).toBe ('pending')
}) })
expect (api.apiPost).toHaveBeenCalledWith ('/posts/bulk', expect.any (FormData))
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')
}) })
}) })
+6 -5
ファイルの表示
@@ -169,16 +169,17 @@ const buildInitialRows = (urls: string[]): PostImportRow[] => {
urls.map ((url, index) => { urls.map ((url, index) => {
const sourceRow = index + 1 const sourceRow = index + 1
const rowIssues = issuesByRow[sourceRow] ?? [] const rowIssues = issuesByRow[sourceRow] ?? []
const validationErrors: Record<string, string[]> =
rowIssues.length > 0
? { url: [...rowIssues] }
: { }
return { return {
sourceRow, sourceRow,
url, url,
attributes: emptyAttributes (), attributes: emptyAttributes (),
fieldWarnings: { }, fieldWarnings: { },
baseWarnings: [], baseWarnings: [],
validationErrors: validationErrors,
rowIssues.length > 0
? { url: [...rowIssues] }
: { },
provenance: emptyProvenance (), provenance: emptyProvenance (),
tagSources: emptyTagSources (), tagSources: emptyTagSources (),
status: rowIssues.length > 0 ? 'error' : 'pending', status: rowIssues.length > 0 ? 'error' : 'pending',
@@ -481,7 +482,7 @@ const mergePreviewRow = (
nextRow.attributes.tags = preview.tags ?? '' nextRow.attributes.tags = preview.tags ?? ''
nextRow.displayTags = cloneDisplayTags (preview.displayTags) nextRow.displayTags = cloneDisplayTags (preview.displayTags)
} }
nextRow.tagSources.automatic = preview.tags ?? '' nextRow.tagSources!.automatic = preview.tags ?? ''
if (currentRow.provenance.parentPostIds !== 'manual') if (currentRow.provenance.parentPostIds !== 'manual')
nextRow.attributes.parentPostIds = String (preview.parentPostIds ?? '') nextRow.attributes.parentPostIds = String (preview.parentPostIds ?? '')
if (currentRow.provenance.duration !== 'manual') if (currentRow.provenance.duration !== 'manual')
+20 -47
ファイルの表示
@@ -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 { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage' import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
import { buildUser } from '@/test/factories' import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiPost: vi.fn (),
isApiError: vi.fn () }))
const router = vi.hoisted (() => ({ navigate: 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 => ({ vi.mock ('react-router-dom', async importOriginal => ({
...await importOriginal<typeof import('react-router-dom')> (), ...await importOriginal<typeof import('react-router-dom')> (),
useNavigate: () => router.navigate })) useNavigate: () => router.navigate }))
@@ -24,71 +15,53 @@ describe ('PostImportSourcePage', () => {
beforeEach (() => { beforeEach (() => {
sessionStorage.clear () sessionStorage.clear ()
vi.clearAllMocks () 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 (<PostImportSourcePage user={buildUser ()}/>) renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument () expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '次へ' })) fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument () 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 (<PostImportSourcePage user={buildUser ()}/>) renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
const input = screen.getByRole ('textbox', { name: '' }) const input = screen.getByRole ('textbox', { name: '' })
fireEvent.change (input, { fireEvent.change (input, {
target: { value: '\nftp://example.com/file\nhttps://example.com/valid' } }) target: { value: '\nftp://example.com/file\nhttps://example.com/valid' } })
expect (screen.queryByText (/2 行目/)).not.toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '次へ' })) fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (screen.getByText (/2 行目: HTTP または HTTPS/)).toBeInTheDocument () expect (screen.getByText (/2 行目: HTTP または HTTPS/)).toBeInTheDocument ()
expect (screen.getByText ('ftp://example.com/file')).toBeInTheDocument () expect (screen.getByText ('ftp://example.com/file')).toBeInTheDocument ()
expect (input).toHaveAttribute ('aria-invalid', 'true') expect (input).toHaveAttribute ('aria-invalid', 'true')
expect (input.getAttribute ('aria-describedby')).toContain ('post-import-source-issues')
expect (api.apiPost).not.toHaveBeenCalled ()
})
it ('shows backend URL errors against original input without a session', async () => {
api.apiPost.mockResolvedValue ({
rows: [buildPostImportRow ({
sourceRow: 2,
url: 'https://example.com/canonical',
status: 'error',
validationErrors: { url: ['URL が重複しています.'] } })] })
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
target: { value: '\nhttps://example.com/original' } })
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (await screen.findByText ('2 行目: URL が重複しています.')).toBeInTheDocument ()
expect (screen.getAllByText ('https://example.com/original')).toHaveLength (2)
expect (router.navigate).not.toHaveBeenCalled () expect (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 () => { it ('navigates with individually encoded URLs without calling an API', () => {
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ()] })
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>) renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
fireEvent.change (screen.getByRole ('textbox', { name: '' }), { 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: '次へ' })) fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
await waitFor (() => {
expect (router.navigate).toHaveBeenCalledWith ( expect (router.navigate).toHaveBeenCalledWith (
expect.stringMatching (/^\/posts\/new\?/)) '/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
+ '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc')
}) })
expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
sessionStorage.key (index))).not.toContainEqual( it ('disables Next when the encoded request target reaches 4096 bytes', () => {
expect.stringMatching (/^post-import-session:/)) renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
const input = screen.getByRole ('textbox', { name: '' })
fireEvent.change (input, {
target: { value: `https://example.com/${ 'a'.repeat (4_100) }` } })
expect (screen.getByRole ('button', { name: '次へ' })).toBeDisabled ()
}) })
}) })
+9 -13
ファイルの表示
@@ -1,5 +1,4 @@
import { screen } from '@testing-library/react' import { screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostNewPage from '@/pages/posts/PostNewPage' import PostNewPage from '@/pages/posts/PostNewPage'
@@ -7,6 +6,7 @@ import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (), apiPost: vi.fn (),
isApiError: vi.fn () })) isApiError: vi.fn () }))
@@ -16,27 +16,23 @@ describe ('PostNewPage', () => {
beforeEach (() => { beforeEach (() => {
vi.clearAllMocks () vi.clearAllMocks ()
api.isApiError.mockReturnValue (false) api.isApiError.mockReturnValue (false)
api.apiGet.mockResolvedValue ({
url: 'https://example.com/post',
title: 'post',
tags: '' })
sessionStorage.clear () sessionStorage.clear ()
}) })
it ('shows the source page on /posts/new', () => { it ('shows the source page on /posts/new', () => {
renderWithProviders ( renderWithProviders (<PostNewPage user={buildUser ()}/>, {
<MemoryRouter initialEntries={['/posts/new']}> route: '/posts/new' })
<Routes>
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument () expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
}) })
it ('shows the review page when query state is present', () => { it ('shows the review page when query state is present', () => {
renderWithProviders ( renderWithProviders (<PostNewPage user={buildUser ()}/>, {
<MemoryRouter initialEntries={['/posts/new?url=https%3A%2F%2Fexample.com%2Fpost']}> route: '/posts/new?urls=https%3A%2F%2Fexample.com%2Fpost' })
<Routes>
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument () expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument () expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
+4 -5
ファイルの表示
@@ -6,7 +6,6 @@ import { dateString } from '@/lib/utils'
import { buildTag, buildUser } from '@/test/factories' import { buildTag, buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
import type { ReactNode } from 'react'
import type { NicoTag } from '@/types' import type { NicoTag } from '@/types'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
@@ -27,10 +26,10 @@ const scrollIntoView = vi.fn ()
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({ vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>, ...await importOriginal<
useDialogue: () => dialogue, typeof import('@/components/dialogues/DialogueProvider')> (),
})) useDialogue: () => dialogue }))
const buildNicoTag = (values: Partial<NicoTag> = {}): NicoTag => ({ const buildNicoTag = (values: Partial<NicoTag> = {}): NicoTag => ({
...buildTag (), ...buildTag (),
+4 -4
ファイルの表示
@@ -40,10 +40,10 @@ const postEmbed = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({ vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>, ...await importOriginal<
useDialogue: () => dialogue, typeof import('@/components/dialogues/DialogueProvider')> (),
})) useDialogue: () => dialogue }))
vi.mock ('@/components/PostEmbed', () => ({ vi.mock ('@/components/PostEmbed', () => ({
default: (props: { default: (props: {
ref?: { current: unknown } ref?: { current: unknown }
+1 -1
ファイルの表示
@@ -1,4 +1,4 @@
import type { PostImportRow } from '@/lib/postImportSession' import type { PostImportRow } from '@/lib/postImportTypes'
export const buildPostImportRow = ( export const buildPostImportRow = (
overrides: Partial<PostImportRow> = {}, overrides: Partial<PostImportRow> = {},
+27 -9
ファイルの表示
@@ -1,7 +1,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import { createContext, useContext, useState } from 'react'
import { HelmetProvider } from 'react-helmet-async' 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 DialogueProvider from '@/components/dialogues/DialogueProvider'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
@@ -13,6 +14,20 @@ type Options = {
route?: string route?: string
} }
const TestContentContext = createContext<ReactNode> (null)
const TestRoute = () => {
const children = useContext (TestContentContext)
return (
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
{children}
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>)
}
export const renderWithProviders = ( export const renderWithProviders = (
ui: ReactElement, ui: ReactElement,
options: Options = {}, options: Options = {},
@@ -22,20 +37,23 @@ export const renderWithProviders = (
queries: { retry: false } }, queries: { retry: false } },
}) })
const Wrapper = ({ children }: { children: ReactNode }) => ( const Wrapper = ({ children }: { children: ReactNode }) => {
const [router] = useState (() => createMemoryRouter ([{
path: '*',
element: <TestRoute/> }], {
initialEntries: [options.route ?? '/'] }))
return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<HelmetProvider> <HelmetProvider>
<MemoryRouter initialEntries={[options.route ?? '/']}>
<DialogueProvider> <DialogueProvider>
<UnsavedChangesGuardProvider> <TestContentContext.Provider value={children}>
<KeyboardShortcutsProvider> <RouterProvider router={router}/>
{children} </TestContentContext.Provider>
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider> </DialogueProvider>
</MemoryRouter>
</HelmetProvider> </HelmetProvider>
</QueryClientProvider>) </QueryClientProvider>)
}
return { return {
queryClient, queryClient,