コミットを比較
2 コミット
f662dc9dc0
...
2f87669699
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 2f87669699 | |||
| 1c906d7432 |
@@ -732,6 +732,7 @@ class PostsController < ApplicationController
|
||||
|
||||
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
|
||||
tag_names:, video_ms_param:, duration_param:, parent_post_ids:
|
||||
validate_original_created_values!(original_created_from, original_created_before)
|
||||
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
|
||||
with_sections: true) =>
|
||||
{ tags:, sections: }
|
||||
@@ -769,6 +770,23 @@ class PostsController < ApplicationController
|
||||
value.to_s
|
||||
end
|
||||
|
||||
def validate_original_created_values! original_created_from, original_created_before
|
||||
candidate = Post.new(
|
||||
url: 'https://example.invalid/original-created-validation',
|
||||
original_created_from:,
|
||||
original_created_before:)
|
||||
candidate.valid?
|
||||
fields = [:original_created_from, :original_created_before, :original_created_at]
|
||||
relevant_errors = candidate.errors.select { fields.include?(_1.attribute) }
|
||||
return if relevant_errors.empty?
|
||||
|
||||
invalid_post = Post.new
|
||||
relevant_errors.each { |error|
|
||||
invalid_post.errors.add(error.attribute, error.message)
|
||||
}
|
||||
raise ActiveRecord::RecordInvalid, invalid_post
|
||||
end
|
||||
|
||||
def section_literal section
|
||||
"[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
|
||||
end
|
||||
|
||||
@@ -87,7 +87,7 @@ class PostCreatePlan
|
||||
end
|
||||
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
|
||||
|
||||
def build_default_tag_specs direct_tag_specs
|
||||
@@ -201,7 +201,7 @@ class PostCreatePlan
|
||||
|
||||
def serialised_tags direct_tag_specs, tag_sections
|
||||
direct_tag_specs.map { |spec|
|
||||
"#{ spec[:name] }#{ tag_sections[spec[:name]].to_a.map { Post.section_literal(_1) }.join }"
|
||||
"#{ spec[:name] }#{ tag_sections[spec[:name]].to_a.map { section_literal(_1) }.join }"
|
||||
}.sort.join(' ')
|
||||
end
|
||||
|
||||
@@ -210,10 +210,15 @@ class PostCreatePlan
|
||||
{
|
||||
name: spec[:name],
|
||||
category: spec[:category].to_s,
|
||||
section_literals: tag_sections[spec[:name]].to_a.map { Post.section_literal(_1) } }
|
||||
section_literals: tag_sections[spec[:name]].to_a.map { section_literal(_1) } }
|
||||
}.sort_by { _1[:name] }
|
||||
end
|
||||
|
||||
def section_literal range
|
||||
begin_ms, end_ms = range
|
||||
"[#{ Post.ms_to_time(begin_ms) }-#{ end_ms ? Post.ms_to_time(end_ms) : '' }]"
|
||||
end
|
||||
|
||||
def normalise_video_ms snapshot_tag_specs
|
||||
return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME }
|
||||
|
||||
@@ -254,4 +259,11 @@ class PostCreatePlan
|
||||
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
|
||||
|
||||
@@ -3,14 +3,14 @@ require 'timeout'
|
||||
|
||||
class PostImportPreviewer
|
||||
FIELDS = [
|
||||
'title',
|
||||
'thumbnail_base',
|
||||
'original_created_from',
|
||||
'original_created_before',
|
||||
'video_ms',
|
||||
'duration',
|
||||
'tags',
|
||||
'parent_post_ids'].freeze
|
||||
'title',
|
||||
'thumbnail_base',
|
||||
'original_created_from',
|
||||
'original_created_before',
|
||||
'video_ms',
|
||||
'duration',
|
||||
'tags',
|
||||
'parent_post_ids'].freeze
|
||||
FETCH_WARNING_FIELDS = ['url', 'title', 'thumbnail_base'].freeze
|
||||
TITLE_FETCH_WARNING = 'タイトルを取得できませんでした.'.freeze
|
||||
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
|
||||
@@ -126,7 +126,6 @@ class PostImportPreviewer
|
||||
validate_basic_data(attributes, validation_errors)
|
||||
validate_preview_tags(merged_tags(tag_sources, provenance['tags']),
|
||||
validation_errors,
|
||||
field_warnings,
|
||||
known_tags)
|
||||
validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids)
|
||||
attributes.delete('url')
|
||||
@@ -450,7 +449,7 @@ class PostImportPreviewer
|
||||
[]
|
||||
end
|
||||
|
||||
def validate_preview_tags raw, errors, field_warnings, known_tags
|
||||
def validate_preview_tags raw, errors, known_tags
|
||||
names = raw.to_s.split
|
||||
return if names.empty?
|
||||
if names.any? { _1.downcase.start_with?('nico:') }
|
||||
@@ -462,11 +461,6 @@ class PostImportPreviewer
|
||||
existing = parsed.filter_map { known_tags[_1] }
|
||||
deprecated = existing.select(&:deprecated?).map(&:name)
|
||||
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
|
||||
errors[:tags] = ['タグ区間の記法が不正です.']
|
||||
end
|
||||
|
||||
@@ -110,7 +110,7 @@ RSpec.describe Post, type: :model do
|
||||
end
|
||||
|
||||
describe '.resized_thumbnail_attachment' do
|
||||
it 'centre-crops a wide image to 180x180 without distorting it' do
|
||||
it 'fits a wide image within 180x180 without distorting it' do
|
||||
blob = image_blob(
|
||||
width: 360,
|
||||
height: 180,
|
||||
@@ -123,13 +123,13 @@ RSpec.describe Post, type: :model do
|
||||
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
|
||||
image = read_image(resized)
|
||||
|
||||
expect(image.dimensions).to eq([180, 180])
|
||||
expect_green(colour_at(image, 0, 90))
|
||||
expect_green(colour_at(image, 90, 90))
|
||||
expect_green(colour_at(image, 179, 90))
|
||||
expect(image.dimensions).to eq([180, 90])
|
||||
expect_red(colour_at(image, 0, 45))
|
||||
expect_green(colour_at(image, 90, 45))
|
||||
expect_red(colour_at(image, 179, 45))
|
||||
end
|
||||
|
||||
it 'centre-crops a tall image to 180x180 without distorting it' do
|
||||
it 'fits a tall image within 180x180 without distorting it' do
|
||||
blob = image_blob(
|
||||
width: 180,
|
||||
height: 360,
|
||||
@@ -142,10 +142,10 @@ RSpec.describe Post, type: :model do
|
||||
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
|
||||
image = read_image(resized)
|
||||
|
||||
expect(image.dimensions).to eq([180, 180])
|
||||
expect_green(colour_at(image, 90, 0))
|
||||
expect_green(colour_at(image, 90, 90))
|
||||
expect_green(colour_at(image, 90, 179))
|
||||
expect(image.dimensions).to eq([90, 180])
|
||||
expect_red(colour_at(image, 45, 0))
|
||||
expect_green(colour_at(image, 45, 90))
|
||||
expect_red(colour_at(image, 45, 179))
|
||||
end
|
||||
|
||||
it 'keeps a square image square without distortion' do
|
||||
@@ -192,7 +192,7 @@ RSpec.describe Post, type: :model do
|
||||
|
||||
expect(post.thumbnail).to be_attached
|
||||
image = read_image(post.thumbnail)
|
||||
expect(image.dimensions).to eq([180, 180])
|
||||
expect(image.dimensions).to eq([180, 135])
|
||||
end
|
||||
|
||||
it 'does not attach anything when thumbnail conversion fails' do
|
||||
|
||||
@@ -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
|
||||
@@ -9,11 +9,11 @@ RSpec.describe 'Posts API', type: :request do
|
||||
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
|
||||
before do
|
||||
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
|
||||
allow(Post).to receive(:resized_thumbnail_attachment).and_return(
|
||||
io: StringIO.new('dummy'),
|
||||
filename: 'resized_thumbnail.jpg',
|
||||
content_type: 'image/jpeg'
|
||||
)
|
||||
allow(Post).to receive(:resized_thumbnail_attachment) do
|
||||
{ io: StringIO.new('dummy'),
|
||||
filename: 'resized_thumbnail.jpg',
|
||||
content_type: 'image/jpeg' }
|
||||
end
|
||||
end
|
||||
|
||||
def create_nico_tag!(name)
|
||||
@@ -21,8 +21,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
end
|
||||
|
||||
def dummy_upload
|
||||
# 中身は何でもいい(加工処理はスタブしてる)
|
||||
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
|
||||
real_thumbnail_upload
|
||||
end
|
||||
|
||||
def real_thumbnail_upload
|
||||
@@ -693,6 +692,73 @@ RSpec.describe 'Posts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /posts/metadata' do
|
||||
let(:member) { create(:user, :member) }
|
||||
|
||||
it 'returns compact existing post data without fetching external metadata' do
|
||||
sign_in_as(member)
|
||||
existing = create(
|
||||
:post,
|
||||
title: 'existing post',
|
||||
url: 'https://example.com/existing')
|
||||
existing.thumbnail.attach(
|
||||
io: StringIO.new('thumbnail'),
|
||||
filename: 'thumbnail.jpg',
|
||||
content_type: 'image/jpeg')
|
||||
expect(PostMetadataFetcher).not_to receive(:fetch)
|
||||
|
||||
get '/posts/metadata', params: { url: 'https://example.com/existing' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'url' => existing.url,
|
||||
'existing_post_id' => existing.id,
|
||||
'field_warnings' => { })
|
||||
expect(json.fetch('existing_post')).to include(
|
||||
'id' => existing.id,
|
||||
'title' => existing.title,
|
||||
'url' => existing.url,
|
||||
'thumbnail_base' => existing.thumbnail_base)
|
||||
expect(json.dig('existing_post', 'thumbnail'))
|
||||
.to include('/rails/active_storage/blobs/proxy/')
|
||||
end
|
||||
|
||||
it 'returns fetched metadata and structured display tags' do
|
||||
sign_in_as(member)
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
allow(PostMetadataFetcher).to receive(:fetch).and_return(
|
||||
title: 'fetched title',
|
||||
thumbnail_base: 'https://example.com/thumbnail.jpg',
|
||||
tags: 'character:虹夏',
|
||||
display_tags: [{ name: '虹夏', category: 'character' }],
|
||||
original_created_from: nil,
|
||||
original_created_before: nil,
|
||||
duration: '1:00',
|
||||
video_ms: 60_000)
|
||||
|
||||
get '/posts/metadata', params: { url: 'https://example.com/new' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'url' => 'https://example.com/new',
|
||||
'title' => 'fetched title',
|
||||
'duration' => '1:00',
|
||||
'video_ms' => 60_000,
|
||||
'field_warnings' => { })
|
||||
expect(json.fetch('display_tags')).to eq(
|
||||
[{ 'name' => '虹夏', 'category' => 'character' }])
|
||||
end
|
||||
|
||||
it 'returns URL validation errors as 422' do
|
||||
sign_in_as(member)
|
||||
|
||||
get '/posts/metadata', params: { url: 'file:///etc/passwd' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to have_key('url')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /posts' do
|
||||
let(:member) { create(:user, :member) }
|
||||
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
|
||||
@@ -712,6 +778,46 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
|
||||
it 'dry-runs without persisting posts or new tags' do
|
||||
sign_in_as(member)
|
||||
counts = [Post.count, Tag.count, TagName.count]
|
||||
|
||||
post '/posts?dry=1', params: post_write_params(
|
||||
title: 'dry-run post',
|
||||
url: 'https://example.com/dry-run',
|
||||
tags: 'character:new_dry_run_tag')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'url' => 'https://example.com/dry-run',
|
||||
'tags' => 'new_dry_run_tag',
|
||||
'existing_post_id' => nil)
|
||||
expect(json.fetch('display_tags')).to eq(
|
||||
[{ 'name' => 'new_dry_run_tag',
|
||||
'category' => 'character',
|
||||
'section_literals' => [] }])
|
||||
expect([Post.count, Tag.count, TagName.count]).to eq(counts)
|
||||
end
|
||||
|
||||
it 'dry-runs an existing URL without validating its upload' do
|
||||
sign_in_as(member)
|
||||
existing = create(:post, url: 'https://example.com/dry-existing')
|
||||
invalid_upload = Rack::Test::UploadedFile.new(
|
||||
StringIO.new('<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
|
||||
sign_in_as(member)
|
||||
|
||||
@@ -769,7 +875,8 @@ RSpec.describe 'Posts API', type: :request do
|
||||
)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
expect(open_transactions).to eq([baseline_open_transactions])
|
||||
expect(open_transactions).to eq(
|
||||
[baseline_open_transactions, baseline_open_transactions])
|
||||
end
|
||||
|
||||
it 'returns 422 and does not create a post when thumbnail resize fails' do
|
||||
@@ -827,7 +934,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'tags' => ['廃止済みタグは付与できません.']
|
||||
'tags' => ['廃止済みタグがあります: deprecated_direct_tag']
|
||||
)
|
||||
end
|
||||
|
||||
@@ -1251,6 +1358,47 @@ RSpec.describe 'Posts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /posts/bulk' do
|
||||
let(:member) { create(:user, :member) }
|
||||
|
||||
it 'parses the manifest and indexed thumbnail parts' do
|
||||
sign_in_as(member)
|
||||
manifest = [{ 'url' => 'https://example.com/bulk',
|
||||
'title' => 'bulk post',
|
||||
'tags' => 'spec_tag',
|
||||
'parent_post_ids' => '' }]
|
||||
creator = instance_double(
|
||||
PostBulkCreator,
|
||||
run: { results: [{ status: 'created', post: { id: 123 } }] })
|
||||
allow(PostBulkCreator).to receive(:new).and_return(creator)
|
||||
|
||||
post '/posts/bulk', params: {
|
||||
posts: JSON.generate(manifest),
|
||||
thumbnails: { '0' => real_thumbnail_upload } }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('results')).to eq(
|
||||
[{ 'status' => 'created', 'post' => { 'id' => 123 } }])
|
||||
expect(PostBulkCreator).to have_received(:new) do |arguments|
|
||||
expect(arguments[:actor]).to eq(member)
|
||||
expect(arguments[:posts]).to eq(manifest)
|
||||
expect(arguments[:thumbnails].keys).to eq([0])
|
||||
expect(arguments[:host]).to eq('http://www.example.com')
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects malformed manifests as a request-level error' do
|
||||
sign_in_as(member)
|
||||
|
||||
post '/posts/bulk', params: {
|
||||
posts: '{',
|
||||
thumbnails: { '0' => real_thumbnail_upload } }
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(json.fetch('message')).to eq('posts manifest の JSON が不正です.')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /posts/:id' do
|
||||
let(:member) { create(:user, :member) }
|
||||
|
||||
@@ -2271,9 +2419,9 @@ RSpec.describe 'Posts API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
expect(Time.iso8601(json.fetch('original_created_from')))
|
||||
.to eq(Time.iso8601('2020-01-01T00:00Z'))
|
||||
.to eq(Time.utc(2020, 1, 1, 0, 0))
|
||||
expect(Time.iso8601(json.fetch('original_created_before')))
|
||||
.to eq(Time.iso8601('2020-01-01T00:01Z'))
|
||||
.to eq(Time.utc(2020, 1, 1, 0, 1))
|
||||
end
|
||||
|
||||
it 'rejects unparseable original created timestamps on PUT /posts/:id' do
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -20,12 +20,12 @@ RSpec.describe PostCreator do
|
||||
allow(PostVersionRecorder).to receive(:record!)
|
||||
end
|
||||
|
||||
it 'prefers an explicit upload over thumbnail_base' do
|
||||
allow(Post).to receive(:resized_thumbnail_attachment).and_return(
|
||||
io: StringIO.new('upload'),
|
||||
it 'prefers thumbnail_base over an explicit upload' do
|
||||
expect(Post).not_to receive(:resized_thumbnail_attachment)
|
||||
allow(Post).to receive(:remote_thumbnail_attachment).and_return(
|
||||
io: StringIO.new('remote'),
|
||||
filename: 'resized_thumbnail.jpg',
|
||||
content_type: 'image/jpeg')
|
||||
expect_any_instance_of(Post).not_to receive(:attach_thumbnail_from_url!)
|
||||
|
||||
post = described_class.new(
|
||||
actor:,
|
||||
@@ -41,13 +41,12 @@ RSpec.describe PostCreator do
|
||||
end
|
||||
|
||||
it 'uses the common remote thumbnail attach path when thumbnail_base is given' do
|
||||
expect_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
|
||||
.with('https://example.com/thumb.jpg') do |post, _url|
|
||||
post.thumbnail.attach(
|
||||
io: StringIO.new('thumbnail'),
|
||||
filename: 'thumbnail.jpg',
|
||||
content_type: 'image/jpeg')
|
||||
end
|
||||
expect(Post).to receive(:remote_thumbnail_attachment)
|
||||
.with('https://example.com/thumb.jpg')
|
||||
.and_return(
|
||||
io: StringIO.new('thumbnail'),
|
||||
filename: 'thumbnail.jpg',
|
||||
content_type: 'image/jpeg')
|
||||
|
||||
post = described_class.new(
|
||||
actor:,
|
||||
@@ -61,8 +60,8 @@ RSpec.describe PostCreator do
|
||||
expect(post.thumbnail).to be_attached
|
||||
end
|
||||
|
||||
it 'keeps creating the post and records a warning when remote thumbnail fetch fails' do
|
||||
allow_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
|
||||
it 'does not create a post when remote thumbnail fetch fails' do
|
||||
allow(Post).to receive(:remote_thumbnail_attachment)
|
||||
.and_raise(Post::RemoteThumbnailFetchFailed, 'サムネール画像を取得できませんでした.')
|
||||
creator = described_class.new(
|
||||
actor:,
|
||||
@@ -72,11 +71,8 @@ RSpec.describe PostCreator do
|
||||
thumbnail_base: 'https://example.com/thumb.jpg',
|
||||
tags: '' })
|
||||
|
||||
post = creator.create!
|
||||
|
||||
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
|
||||
expect(post.thumbnail).not_to be_attached
|
||||
expect(creator.field_warnings).to eq(
|
||||
thumbnail_base: ['サムネール画像を取得できませんでした.'])
|
||||
post_count = Post.count
|
||||
expect { creator.create! }.to raise_error(Post::RemoteThumbnailFetchFailed)
|
||||
expect(Post.count).to eq(post_count)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -2,7 +2,7 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Preview::ThumbnailFetcher do
|
||||
describe '.fetch' do
|
||||
it 'rejects svg thumbnails' do
|
||||
it 'accepts svg thumbnails for the common safe rasterisation path' do
|
||||
page = Preview::HttpFetcher::Response.new(
|
||||
'<meta property="og:image" content="https://example.com/thumb.svg">',
|
||||
'text/html',
|
||||
@@ -12,8 +12,9 @@ RSpec.describe Preview::ThumbnailFetcher do
|
||||
'image/svg+xml',
|
||||
'https://example.com/thumb.svg')
|
||||
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
|
||||
allow(Preview::UrlSafety).to receive(:validate) do |url|
|
||||
[URI.parse(url), ['203.0.113.10']]
|
||||
end
|
||||
allow(Preview::HttpFetcher).to receive(:fetch)
|
||||
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
|
||||
.and_return(page)
|
||||
@@ -21,9 +22,7 @@ RSpec.describe Preview::ThumbnailFetcher do
|
||||
.with('https://example.com/thumb.svg')
|
||||
.and_return(svg)
|
||||
|
||||
expect {
|
||||
described_class.fetch('https://example.com/page')
|
||||
}.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
|
||||
expect(described_class.fetch('https://example.com/page')).to eq('<svg></svg>')
|
||||
end
|
||||
|
||||
it 'accepts allowed image content type with parameters' do
|
||||
@@ -32,12 +31,13 @@ RSpec.describe Preview::ThumbnailFetcher do
|
||||
'text/html',
|
||||
'https://example.com/page')
|
||||
image = Preview::HttpFetcher::Response.new(
|
||||
'jpeg-bytes',
|
||||
"\xFF\xD8\xFFjpeg-bytes".b,
|
||||
'image/jpeg; charset=binary',
|
||||
'https://example.com/thumb.jpg')
|
||||
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
|
||||
allow(Preview::UrlSafety).to receive(:validate) do |url|
|
||||
[URI.parse(url), ['203.0.113.10']]
|
||||
end
|
||||
allow(Preview::HttpFetcher).to receive(:fetch)
|
||||
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
|
||||
.and_return(page)
|
||||
@@ -45,7 +45,8 @@ RSpec.describe Preview::ThumbnailFetcher do
|
||||
.with('https://example.com/thumb.jpg')
|
||||
.and_return(image)
|
||||
|
||||
expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes')
|
||||
expect(described_class.fetch('https://example.com/page'))
|
||||
.to eq("\xFF\xD8\xFFjpeg-bytes".b)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -73,11 +73,11 @@ RSpec.describe "nico:sync" do
|
||||
Tag.tagme
|
||||
|
||||
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
|
||||
allow(URI).to receive(:open)
|
||||
.and_return(
|
||||
StringIO.new(
|
||||
'<meta name="thumbnail" content="https://example.com/thumb.jpg">'))
|
||||
expect(post).to receive(:attach_thumbnail_from_url!)
|
||||
allow(URI).to receive(:open) do
|
||||
StringIO.new(
|
||||
'<meta name="thumbnail" content="https://example.com/thumb.jpg">')
|
||||
end
|
||||
expect_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
|
||||
.with('https://example.com/thumb.jpg')
|
||||
|
||||
run_rake_task('nico:sync')
|
||||
@@ -92,18 +92,20 @@ RSpec.describe "nico:sync" do
|
||||
Tag.tagme
|
||||
|
||||
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
|
||||
allow(URI).to receive(:open)
|
||||
.and_return(
|
||||
StringIO.new(
|
||||
'<meta name="thumbnail" content="https://example.com/thumb.jpg">'))
|
||||
expect(post).to receive(:attach_thumbnail_from_url!)
|
||||
.with('https://example.com/thumb.jpg')
|
||||
.twice
|
||||
.and_raise(Post::RemoteThumbnailFetchFailed, 'failed')
|
||||
allow(URI).to receive(:open) do
|
||||
StringIO.new(
|
||||
'<meta name="thumbnail" content="https://example.com/thumb.jpg">')
|
||||
end
|
||||
calls = 0
|
||||
allow_any_instance_of(Post).to receive(:attach_thumbnail_from_url!) do
|
||||
calls += 1
|
||||
raise Post::RemoteThumbnailFetchFailed, 'failed'
|
||||
end
|
||||
|
||||
2.times do
|
||||
run_rake_task('nico:sync')
|
||||
end
|
||||
expect(calls).to eq(2)
|
||||
end
|
||||
|
||||
it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do
|
||||
|
||||
@@ -78,16 +78,18 @@ describe ('PostEditForm', () => {
|
||||
|
||||
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]
|
||||
fireEvent.change (tags, { target: { value: 'general-tag' } })
|
||||
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByText ('動画時間')).not.toBeInTheDocument ()
|
||||
|
||||
fireEvent.change (tags, {
|
||||
target: { value: '動画 general-tag' },
|
||||
})
|
||||
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
|
||||
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
|
||||
.toHaveValue ('180.5')
|
||||
})
|
||||
|
||||
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
|
||||
|
||||
@@ -73,7 +73,7 @@ describe ('PostOriginalCreatedTimeField', () => {
|
||||
setOriginalCreatedBefore={vi.fn ()}/>,
|
||||
)
|
||||
|
||||
const input = screen.getDisplayValue ('2024-01-01T12:34')
|
||||
const input = screen.getByDisplayValue ('2024-01-01T12:34')
|
||||
fireEvent.change (input, { target: { value: '2024-01-01T12:35' } })
|
||||
|
||||
expect (setFrom).toHaveBeenCalledWith ('2024-01-01T03:35Z')
|
||||
|
||||
@@ -17,21 +17,19 @@ describe ('menuOutline', () => {
|
||||
for (const role of ['member', 'admin'] as const)
|
||||
{
|
||||
expect (submenuItem (role, '広場', '追加')?.visible).toBe (true)
|
||||
expect (submenuItem (role, '広場', '取込')?.visible).toBe (true)
|
||||
expect (submenuItem (role, '素材', '追加')?.visible).toBe (true)
|
||||
expect (submenuItem (role, 'Wiki', '新規')?.visible).toBe (true)
|
||||
expect (submenuItem (role, 'Wiki', '編輯')?.visible).toBe (true)
|
||||
}
|
||||
|
||||
expect (submenuItem ('guest', '広場', '追加')?.visible).toBe (false)
|
||||
expect (submenuItem ('guest', '広場', '取込')?.visible).toBe (false)
|
||||
expect (submenuItem ('guest', '素材', '追加')?.visible).toBe (false)
|
||||
expect (submenuItem ('guest', 'Wiki', '新規')?.visible).toBe (false)
|
||||
expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false)
|
||||
})
|
||||
|
||||
it ('uses /posts/new as the import entrypoint', () => {
|
||||
expect (submenuItem ('member', '広場', '取込')?.to).toBe ('/posts/new')
|
||||
it ('uses /posts/new as the post creation entrypoint', () => {
|
||||
expect (submenuItem ('member', '広場', '追加')?.to).toBe ('/posts/new')
|
||||
})
|
||||
|
||||
it ('keeps material suppression admin-only', () => {
|
||||
|
||||
@@ -80,7 +80,9 @@ describe ('DialogueProvider', () => {
|
||||
expect (dialogue).toHaveClass ('max-h-[calc(100dvh-1rem)]', 'flex-col', 'max-w-3xl')
|
||||
await waitFor (() => expect (screen.getByRole ('button', { name: '左操作' }))
|
||||
.toBeInTheDocument ())
|
||||
expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass ('w-full', 'sm:w-auto')
|
||||
expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass (
|
||||
'w-full',
|
||||
'md:w-auto')
|
||||
fireEvent.click (screen.getByRole ('button', { name: '左操作' }))
|
||||
|
||||
await waitFor (() => expect (action).toHaveBeenCalledTimes (1))
|
||||
|
||||
@@ -2,8 +2,6 @@ import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
|
||||
@@ -14,14 +12,6 @@ vi.mock ('@/components/posts/PostCreationDataFields', () => ({
|
||||
}))
|
||||
|
||||
describe ('PostCreationDataFields usage', () => {
|
||||
it ('is used by PostNewPage', async () => {
|
||||
const { default: PostNewPage } = await import ('@/pages/posts/PostNewPage')
|
||||
|
||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||
|
||||
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('is used by PostImportRowForm', async () => {
|
||||
const { default: PostImportRowForm } = await import (
|
||||
'@/components/posts/import/PostImportRowForm')
|
||||
|
||||
@@ -135,7 +135,7 @@ describe ('PostImportRowForm', () => {
|
||||
})
|
||||
|
||||
it (
|
||||
'shows the shared creation field order without duration and without file upload UI',
|
||||
'shows upload input only when the thumbnail URL is blank',
|
||||
async () => {
|
||||
let actions: DialogueFormAction[] = []
|
||||
const controls: DialogueFormControls = {
|
||||
@@ -164,7 +164,9 @@ describe ('PostImportRowForm', () => {
|
||||
'オリジナルの作成日時',
|
||||
'タグ',
|
||||
'親投稿'])
|
||||
expect (container.querySelector ('input[type="file"]')).toBeNull ()
|
||||
expect (container.querySelector ('input[type="file"]')).toHaveAttribute (
|
||||
'accept',
|
||||
'image/*')
|
||||
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
|
||||
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ import type { FC } from 'react'
|
||||
import type { PostImportRow } from '@/lib/postImportTypes'
|
||||
|
||||
type Props = {
|
||||
row: PostImportRow
|
||||
row: PostImportRow
|
||||
displayNumber?: number
|
||||
onEdit?: () => void
|
||||
onRetry?: () => void
|
||||
onEdit?: () => void
|
||||
onRetry?: () => void
|
||||
onToggleSkip?: (checked: boolean) => void
|
||||
rowMessages?: string[]
|
||||
editDisabled?: boolean
|
||||
@@ -75,123 +75,123 @@ const PostImportRowSummary: FC<Props> = (
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn (
|
||||
'hidden items-center gap-4 rounded-lg border p-4 md:grid',
|
||||
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
|
||||
'transition-shadow hover:shadow-sm')}>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">#{rowNumber}</div>
|
||||
</div>
|
||||
<PostImportThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
file={row.thumbnailFile}
|
||||
className="h-16 w-16"/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="line-clamp-2 text-sm font-medium">
|
||||
{String (row.attributes.title ?? '')}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{row.url}
|
||||
</div>
|
||||
<PostImportTagLinks tags={row.displayTags}/>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{summaryDate (row)}
|
||||
</div>
|
||||
{showDuration && (
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
動画時間 {duration}
|
||||
</div>)}
|
||||
{warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||
{warning}
|
||||
</div>)}
|
||||
<FieldError messages={rowMessages}/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
{skipControl}
|
||||
{showActions && editVisible && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={editDisabled === true || !(editAllowed)}>
|
||||
編輯
|
||||
</Button>)}
|
||||
{showActions && retryAllowed && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
disabled={retryDisabled === true}>
|
||||
再試行
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn (
|
||||
'hidden items-center gap-4 rounded-lg border p-4 md:grid',
|
||||
'md:grid-cols-[4rem_5rem_minmax(0,1fr)_auto_auto]',
|
||||
'transition-shadow hover:shadow-sm')}>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">#{rowNumber}</div>
|
||||
</div>
|
||||
<PostImportThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
file={row.thumbnailFile}
|
||||
className="h-16 w-16"/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="line-clamp-2 text-sm font-medium">
|
||||
{String (row.attributes.title ?? '')}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{row.url}
|
||||
</div>
|
||||
<PostImportTagLinks tags={row.displayTags}/>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{summaryDate (row)}
|
||||
</div>
|
||||
{showDuration && (
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
動画時間 {duration}
|
||||
</div>)}
|
||||
{warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||
{warning}
|
||||
</div>)}
|
||||
<FieldError messages={rowMessages}/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
{skipControl}
|
||||
{showActions && editVisible && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={editDisabled === true || !(editAllowed)}>
|
||||
編輯
|
||||
</Button>)}
|
||||
{showActions && retryAllowed && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
disabled={retryDisabled === true}>
|
||||
再試行
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn (
|
||||
'space-y-3 rounded-lg border p-4 md:hidden',
|
||||
'transition-shadow hover:shadow-sm')}>
|
||||
<div className="text-sm font-medium">#{rowNumber}</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<PostImportThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
file={row.thumbnailFile}
|
||||
className="h-20 w-20 shrink-0"/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="line-clamp-2 text-sm font-medium">
|
||||
{String (row.attributes.title ?? '')}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{row.url}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||
</div>
|
||||
<PostImportTagLinks tags={row.displayTags}/>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{summaryDate (row)}
|
||||
</div>
|
||||
{showDuration && (
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
動画時間 {duration}
|
||||
</div>)}
|
||||
{warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||
{warning}
|
||||
</div>)}
|
||||
<FieldError messages={rowMessages}/>
|
||||
{skipControl}
|
||||
</div>
|
||||
</div>
|
||||
{showActions && (editVisible || retryAllowed) && (
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
{editVisible && (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full md:w-auto"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={editDisabled === true || !(editAllowed)}>
|
||||
編輯
|
||||
</Button>)}
|
||||
{retryAllowed && (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full md:w-auto"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
disabled={retryDisabled === true}>
|
||||
再試行
|
||||
</Button>)}
|
||||
</div>)}
|
||||
</div>
|
||||
<div
|
||||
className={cn (
|
||||
'space-y-3 rounded-lg border p-4 md:hidden',
|
||||
'transition-shadow hover:shadow-sm')}>
|
||||
<div className="text-sm font-medium">#{rowNumber}</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<PostImportThumbnailPreview
|
||||
url={String (row.attributes.thumbnailBase ?? '')}
|
||||
file={row.thumbnailFile}
|
||||
className="h-20 w-20 shrink-0"/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="line-clamp-2 text-sm font-medium">
|
||||
{String (row.attributes.title ?? '')}
|
||||
</div>
|
||||
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{row.url}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||
</div>
|
||||
<PostImportTagLinks tags={row.displayTags}/>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{summaryDate (row)}
|
||||
</div>
|
||||
{showDuration && (
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
動画時間 {duration}
|
||||
</div>)}
|
||||
{warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||
{warning}
|
||||
</div>)}
|
||||
<FieldError messages={rowMessages}/>
|
||||
{skipControl}
|
||||
</div>
|
||||
</div>
|
||||
{showActions && (editVisible || retryAllowed) && (
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
{editVisible && (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full md:w-auto"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
disabled={editDisabled === true || !(editAllowed)}>
|
||||
編輯
|
||||
</Button>)}
|
||||
{retryAllowed && (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full md:w-auto"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
disabled={retryDisabled === true}>
|
||||
再試行
|
||||
</Button>)}
|
||||
</div>)}
|
||||
</div>
|
||||
</>)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const PostImportTagLinks: FC<Props> = ({ tags }) => {
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap text-xs gap-x-1">
|
||||
{tags.map (tag => {
|
||||
const key = `${ tag.category }:${ tag.name }:${ tag.sectionLiterals?.join ('|') ?? '' }`
|
||||
return (
|
||||
@@ -21,6 +21,7 @@ const PostImportTagLinks: FC<Props> = ({ tags }) => {
|
||||
tag={{
|
||||
name: tag.name,
|
||||
category: tag.category }}
|
||||
linkFlg={false}
|
||||
withWiki={false}
|
||||
withCount={false}/>
|
||||
{tag.sectionLiterals?.map (literal => (
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
}))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
|
||||
describe ('PostImportThumbnailPreview', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
@@ -16,69 +10,50 @@ describe ('PostImportThumbnailPreview', () => {
|
||||
globalThis.URL.revokeObjectURL = vi.fn ()
|
||||
})
|
||||
|
||||
it ('uses a backend-fetched blob URL instead of the external thumbnail URL directly', async () => {
|
||||
api.apiGet.mockResolvedValueOnce (new Blob (['img'], { type: 'image/png' }))
|
||||
|
||||
it ('renders the remote URL directly without a backend proxy', () => {
|
||||
render (
|
||||
<PostImportThumbnailPreview
|
||||
url="https://example.com/thumbnail.jpg"
|
||||
className="h-10 w-10"/>)
|
||||
<PostImportThumbnailPreview
|
||||
url="https://example.com/thumbnail.jpg"
|
||||
className="h-10 w-10"/>)
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
|
||||
})
|
||||
expect (screen.getByRole ('img')).not.toHaveAttribute (
|
||||
'src',
|
||||
'https://example.com/thumbnail.jpg')
|
||||
expect (api.apiGet).toHaveBeenCalledWith ('/preview/thumbnail', {
|
||||
params: { url: 'https://example.com/thumbnail.jpg' },
|
||||
responseType: 'blob' })
|
||||
expect (screen.getByRole ('img')).toHaveAttribute (
|
||||
'src',
|
||||
'https://example.com/thumbnail.jpg')
|
||||
expect (screen.getByRole ('img')).toHaveAttribute (
|
||||
'referrerpolicy',
|
||||
'no-referrer')
|
||||
})
|
||||
|
||||
it ('does not render the unsafe URL directly when preview fetching fails', async () => {
|
||||
api.apiGet.mockRejectedValueOnce (new Error ('unsafe'))
|
||||
|
||||
it ('shows the empty frame after the remote image fails', () => {
|
||||
const { container } = render (
|
||||
<PostImportThumbnailPreview
|
||||
url="http://127.0.0.1/private.png"
|
||||
className="h-10 w-10"/>)
|
||||
<PostImportThumbnailPreview
|
||||
url="https://example.com/missing.jpg"
|
||||
className="h-10 w-10"/>)
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.queryByRole ('img')).toBeNull ()
|
||||
})
|
||||
expect (screen.queryByText ('サムネールを表示できません')).toBeNull ()
|
||||
expect (screen.queryByText ('なし')).toBeNull ()
|
||||
fireEvent.error (screen.getByRole ('img'))
|
||||
|
||||
expect (screen.queryByRole ('img')).toBeNull ()
|
||||
expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull ()
|
||||
expect (container.textContent).toBe ('')
|
||||
expect (screen.queryByRole ('img')).toBeNull ()
|
||||
})
|
||||
|
||||
it ('revokes the old object URL when the source URL changes', async () => {
|
||||
const createObjectUrlMock =
|
||||
globalThis.URL.createObjectURL as unknown as ReturnType<typeof vi.fn>
|
||||
createObjectUrlMock
|
||||
.mockReturnValueOnce ('blob:first')
|
||||
.mockReturnValueOnce ('blob:second')
|
||||
api.apiGet
|
||||
.mockResolvedValueOnce (new Blob (['first'], { type: 'image/png' }))
|
||||
.mockResolvedValueOnce (new Blob (['second'], { type: 'image/png' }))
|
||||
it ('uses and revokes an object URL only when the remote URL is blank', () => {
|
||||
const file = new File (['image'], 'thumbnail.png', { type: 'image/png' })
|
||||
const { rerender, unmount } = render (
|
||||
<PostImportThumbnailPreview url="" file={file} className="h-10 w-10"/>)
|
||||
|
||||
const { rerender } = render (
|
||||
<PostImportThumbnailPreview
|
||||
url="https://example.com/first.jpg"
|
||||
className="h-10 w-10"/>)
|
||||
await waitFor (() => {
|
||||
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:first')
|
||||
})
|
||||
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
|
||||
|
||||
rerender (
|
||||
<PostImportThumbnailPreview
|
||||
url="https://example.com/second.jpg"
|
||||
className="h-10 w-10"/>)
|
||||
<PostImportThumbnailPreview
|
||||
url="https://example.com/remote.jpg"
|
||||
file={file}
|
||||
className="h-10 w-10"/>)
|
||||
expect (screen.getByRole ('img')).toHaveAttribute (
|
||||
'src',
|
||||
'https://example.com/remote.jpg')
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:second')
|
||||
})
|
||||
expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:first')
|
||||
unmount ()
|
||||
expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:preview')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,14 +16,14 @@ describe ('displayPostImportStatus', () => {
|
||||
skipReason: 'manual' }))).toBe ('skipped')
|
||||
})
|
||||
|
||||
it ('does not expose validation, failure, or created states as badges', () => {
|
||||
it ('distinguishes validation, failure, and created states', () => {
|
||||
expect (displayPostImportStatus (buildPostImportRow ({
|
||||
status: 'error',
|
||||
validationErrors: { title: ['invalid'] } }))).toBeNull ()
|
||||
validationErrors: { title: ['invalid'] } }))).toBe ('error')
|
||||
expect (displayPostImportStatus (buildPostImportRow ({
|
||||
importStatus: 'failed' }))).toBeNull ()
|
||||
importStatus: 'failed' }))).toBe ('failed')
|
||||
expect (displayPostImportStatus (buildPostImportRow ({
|
||||
importStatus: 'created',
|
||||
createdPostId: 3 }))).toBeNull ()
|
||||
createdPostId: 3 }))).toBe ('created')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ import { creatableImportRows,
|
||||
resultRowMessages,
|
||||
resultSummaryCounts,
|
||||
retryImportRow,
|
||||
reviewSummaryCounts } from '@/lib/postImportSession'
|
||||
reviewSummaryCounts } from '@/lib/postImportRows'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
|
||||
describe ('post import row state', () => {
|
||||
@@ -274,6 +274,7 @@ describe ('post import row state', () => {
|
||||
thumbnailBase: '',
|
||||
originalCreatedFrom: '',
|
||||
originalCreatedBefore: '',
|
||||
duration: '2',
|
||||
tags: 'edited-tag',
|
||||
parentPostIds: '' },
|
||||
true)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { countImportSourceLines, validateImportSource } from '@/lib/postImportSession'
|
||||
import {
|
||||
countImportSourceLines,
|
||||
validateImportSource,
|
||||
} from '@/lib/postImportSourceValidation'
|
||||
|
||||
describe ('post import source validation', () => {
|
||||
it ('counts trimmed non-empty CRLF and LF rows', () => {
|
||||
|
||||
@@ -1,124 +1,31 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { cleanupExpiredPostImportSessions,
|
||||
clearPostImportSourceDraft,
|
||||
loadPostImportSession,
|
||||
loadPostImportSourceDraft,
|
||||
savePostImportSession,
|
||||
savePostImportSourceDraft } from '@/lib/postImportSession'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
import {
|
||||
clearPostImportSourceDraft,
|
||||
loadPostImportSourceDraft,
|
||||
savePostImportSourceDraft,
|
||||
} from '@/lib/postImportStorage'
|
||||
|
||||
describe ('post import storage', () => {
|
||||
describe ('post import source draft storage', () => {
|
||||
beforeEach (() => {
|
||||
sessionStorage.clear ()
|
||||
vi.useRealTimers ()
|
||||
vi.restoreAllMocks ()
|
||||
})
|
||||
|
||||
it ('round-trips a valid session and source draft', () => {
|
||||
const row = buildPostImportRow ({
|
||||
attributes: { duration: '2.5' },
|
||||
recoverable: true,
|
||||
importStatus: 'pending',
|
||||
validationErrors: { title: ['invalid'] } })
|
||||
const skipped = buildPostImportRow ({
|
||||
importStatus: 'skipped',
|
||||
skipReason: 'existing',
|
||||
existingPostId: 10 })
|
||||
const manual = buildPostImportRow ({
|
||||
sourceRow: 3,
|
||||
skipReason: 'manual' })
|
||||
|
||||
expect (savePostImportSession ({
|
||||
source: skipped.url,
|
||||
rows: [row, skipped, manual],
|
||||
repairMode: 'all' })).toBe (true)
|
||||
expect (loadPostImportSession ()).toMatchObject ({
|
||||
version: 2,
|
||||
source: skipped.url,
|
||||
rows: [{
|
||||
attributes: { duration: '2.5' },
|
||||
recoverable: true,
|
||||
importStatus: 'pending' },
|
||||
{
|
||||
importStatus: 'skipped',
|
||||
skipReason: 'existing',
|
||||
existingPostId: 10 },
|
||||
{
|
||||
skipReason: 'manual' }] })
|
||||
|
||||
it ('round-trips and clears the URL list source draft', () => {
|
||||
expect (savePostImportSourceDraft ('https://example.com')).toBe (true)
|
||||
expect (loadPostImportSourceDraft ()).toEqual ({ source: 'https://example.com' })
|
||||
expect (loadPostImportSourceDraft ()).toEqual ({
|
||||
source: 'https://example.com' })
|
||||
|
||||
clearPostImportSourceDraft ()
|
||||
|
||||
expect (loadPostImportSourceDraft ()).toEqual ({ source: '' })
|
||||
})
|
||||
|
||||
it ('rejects inconsistent post IDs and terminal statuses', () => {
|
||||
const session = {
|
||||
version: 2,
|
||||
savedAt: new Date ().toISOString (),
|
||||
source: '',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ()] }
|
||||
const invalidRows = [
|
||||
{ ...session.rows[0], skipReason: 'existing', existingPostId: undefined },
|
||||
{ ...session.rows[0], existingPostId: 2, skipReason: undefined },
|
||||
{ ...session.rows[0], importStatus: 'created', createdPostId: undefined },
|
||||
{ ...session.rows[0], importStatus: 'failed', createdPostId: 3 },
|
||||
{ ...session.rows[0], importStatus: 'created', recoverable: true },
|
||||
{ ...session.rows[0], importStatus: 'skipped', recoverable: true },
|
||||
{ ...session.rows[0], recoverable: true }]
|
||||
it ('ignores malformed stored drafts', () => {
|
||||
sessionStorage.setItem ('post-import-source-draft', '{')
|
||||
|
||||
for (const row of invalidRows)
|
||||
{
|
||||
sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
|
||||
...session,
|
||||
rows: [row] }))
|
||||
expect (loadPostImportSession ()).toBeNull ()
|
||||
sessionStorage.clear ()
|
||||
}
|
||||
})
|
||||
|
||||
it ('rejects invalid attribute, provenance, tag-source, and snapshot data', () => {
|
||||
const row = buildPostImportRow ()
|
||||
const invalidRows = [
|
||||
{ ...row, attributes: { title: [] } },
|
||||
{ ...row, attributes: { unknown: 'value' } },
|
||||
{ ...row, provenance: { title: 'mapped' } },
|
||||
{ ...row, tagSources: { mapped: 'tag' } },
|
||||
{ ...row, resetSnapshot: { ...row.resetSnapshot,
|
||||
fieldWarnings: { title: 'warning' } } }]
|
||||
|
||||
invalidRows.forEach (invalidRow => {
|
||||
sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
|
||||
version: 2,
|
||||
savedAt: new Date ().toISOString (),
|
||||
source: '',
|
||||
rows: [invalidRow],
|
||||
repairMode: 'all' }))
|
||||
expect (loadPostImportSession ()).toBeNull ()
|
||||
sessionStorage.clear ()
|
||||
})
|
||||
})
|
||||
|
||||
it ('keeps only the current fixed-key session and removes legacy prefixed entries', () => {
|
||||
const current = {
|
||||
version: 2,
|
||||
savedAt: new Date ().toISOString (),
|
||||
source: '',
|
||||
rows: [buildPostImportRow ()],
|
||||
repairMode: 'all' }
|
||||
const expired = {
|
||||
...current,
|
||||
savedAt: new Date (Date.now () - 25 * 60 * 60 * 1000).toISOString () }
|
||||
sessionStorage.setItem ('post-import-session:current', JSON.stringify (current))
|
||||
sessionStorage.setItem ('post-import-session:expired', JSON.stringify (expired))
|
||||
sessionStorage.setItem ('post-import-session:malformed', '{')
|
||||
|
||||
cleanupExpiredPostImportSessions ()
|
||||
|
||||
expect (sessionStorage.getItem ('post-import-session:current')).not.toBeNull ()
|
||||
expect (sessionStorage.getItem ('post-import-session:expired')).toBeNull ()
|
||||
expect (sessionStorage.getItem ('post-import-session:malformed')).toBeNull ()
|
||||
expect (loadPostImportSourceDraft ()).toEqual ({ source: '' })
|
||||
})
|
||||
|
||||
it ('reports storage access failures without throwing', () => {
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
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 ()
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,7 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
|
||||
const bypassNextNavigationRef = useRef<symbol | null> (null)
|
||||
const [revision, setRevision] = useState (0)
|
||||
const dialogueOpenRef = useRef (false)
|
||||
const handlingBlockedTransitionRef = useRef (false)
|
||||
|
||||
const registerUnsavedChangesSource = useCallback ((
|
||||
source: UnsavedChangesSource,
|
||||
@@ -73,6 +74,8 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
|
||||
[sources],
|
||||
)
|
||||
const hasUnsavedChanges = dirtySources.length > 0
|
||||
const hasUnsavedChangesRef = useRef (hasUnsavedChanges)
|
||||
hasUnsavedChangesRef.current = hasUnsavedChanges
|
||||
const shouldBlock = useCallback (() => {
|
||||
if (bypassNextNavigationRef.current != null)
|
||||
{
|
||||
@@ -80,11 +83,11 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
|
||||
return false
|
||||
}
|
||||
|
||||
return hasUnsavedChanges
|
||||
}, [hasUnsavedChanges])
|
||||
return hasUnsavedChangesRef.current
|
||||
}, [])
|
||||
const blocker = useBlocker (shouldBlock)
|
||||
|
||||
const discardDirtyChanges = useCallback (async (): Promise<boolean> => {
|
||||
const confirmDiscardChanges = useCallback (async (): Promise<boolean> => {
|
||||
if (!(hasUnsavedChanges))
|
||||
return true
|
||||
|
||||
@@ -102,48 +105,49 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
|
||||
if (!(confirmed))
|
||||
return false
|
||||
|
||||
try
|
||||
{
|
||||
for (const source of dirtySources)
|
||||
await source.discard ()
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
finally
|
||||
{
|
||||
dialogueOpenRef.current = false
|
||||
}
|
||||
}, [dialogue, dirtySources, hasUnsavedChanges])
|
||||
}, [dialogue, hasUnsavedChanges])
|
||||
|
||||
useEffect (() => {
|
||||
if (blocker.state !== 'blocked')
|
||||
if (blocker.state !== 'blocked' || handlingBlockedTransitionRef.current)
|
||||
return
|
||||
|
||||
let active = true
|
||||
|
||||
handlingBlockedTransitionRef.current = true
|
||||
void (async () => {
|
||||
const confirmed = await discardDirtyChanges ()
|
||||
if (!(active))
|
||||
return
|
||||
try
|
||||
{
|
||||
const confirmed = await confirmDiscardChanges ()
|
||||
if (!(confirmed))
|
||||
{
|
||||
blocker.reset ()
|
||||
return
|
||||
}
|
||||
|
||||
if (confirmed)
|
||||
let discardResults: Array<void | Promise<void>>
|
||||
try
|
||||
{
|
||||
blocker.proceed ()
|
||||
return
|
||||
discardResults = dirtySources.map (source => source.discard ())
|
||||
}
|
||||
catch
|
||||
{
|
||||
blocker.reset ()
|
||||
return
|
||||
}
|
||||
|
||||
blocker.reset ()
|
||||
blocker.proceed ()
|
||||
await Promise.allSettled (discardResults)
|
||||
}
|
||||
finally
|
||||
{
|
||||
handlingBlockedTransitionRef.current = false
|
||||
}
|
||||
}) ()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [blocker, discardDirtyChanges])
|
||||
}, [blocker, confirmDiscardChanges, dirtySources])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(hasUnsavedChanges))
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
})
|
||||
@@ -1,448 +1,157 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { loadPostImportSession, savePostImportSession } from '@/lib/postImportSession'
|
||||
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
apiPost: vi.fn (),
|
||||
}))
|
||||
|
||||
const toastApi = vi.hoisted (() => ({
|
||||
toast: vi.fn (),
|
||||
}))
|
||||
|
||||
const dialogue = vi.hoisted (() => ({
|
||||
form: vi.fn (() => Promise.resolve ()),
|
||||
}))
|
||||
isApiError: vi.fn (() => false) }))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
vi.mock ('@/lib/dialogues/useDialogue', () => ({
|
||||
default: () => dialogue,
|
||||
}))
|
||||
vi.mock ('framer-motion', () => ({
|
||||
AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
motion: {
|
||||
div: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
main: ({ children }: { children?: ReactNode }) => <main>{children}</main> } }))
|
||||
|
||||
const renderReviewPage = (route = '/posts/new/review') =>
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
<Routes>
|
||||
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
|
||||
<Route
|
||||
path="/posts/new/review"
|
||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
||||
<Route path="/posts" element={<div>POSTS ROUTE</div>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
const metadata = (url: string, title: string) => ({
|
||||
url,
|
||||
title,
|
||||
thumbnailBase: 'https://example.com/thumbnail.jpg',
|
||||
originalCreatedFrom: '',
|
||||
originalCreatedBefore: '',
|
||||
duration: '',
|
||||
tags: '',
|
||||
parentPostIds: [],
|
||||
fieldWarnings: { },
|
||||
baseWarnings: [],
|
||||
displayTags: [] })
|
||||
|
||||
|
||||
const LocationProbe = () => {
|
||||
const location = useLocation ()
|
||||
return <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', () => {
|
||||
beforeEach (() => {
|
||||
sessionStorage.clear ()
|
||||
vi.clearAllMocks ()
|
||||
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
|
||||
globalThis.URL.revokeObjectURL = vi.fn ()
|
||||
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
|
||||
api.isApiError.mockReturnValue (false)
|
||||
})
|
||||
|
||||
it ('redirects to /posts/new when no current work exists', async () => {
|
||||
renderReviewPage ()
|
||||
it ('fetches metadata with at most four concurrent requests', async () => {
|
||||
const resolvers: Array<(value: ReturnType<typeof metadata>) => void> = []
|
||||
api.apiGet.mockImplementation ((_path, options) =>
|
||||
new Promise (resolve => {
|
||||
resolvers.push (resolve)
|
||||
const url = String (options.params.url)
|
||||
void url
|
||||
}))
|
||||
const urls = Array.from (
|
||||
{ length: 6 },
|
||||
(_, index) => `https://example.com/${ index + 1 }`)
|
||||
|
||||
renderReviewPage (urls)
|
||||
|
||||
await waitFor (() => expect (api.apiGet).toHaveBeenCalledTimes (4))
|
||||
|
||||
resolvers[0]?.(metadata (urls[0]!, 'first'))
|
||||
|
||||
expect (await screen.findAllByText ('first')).toHaveLength (2)
|
||||
await waitFor (() => expect (api.apiGet).toHaveBeenCalledTimes (5))
|
||||
expect (screen.getByRole ('button', { name: '追加' })).toBeDisabled ()
|
||||
|
||||
for (let index = 1; index < resolvers.length; ++index)
|
||||
resolvers[index]?.(metadata (urls[index]!, `post ${ index + 1 }`))
|
||||
})
|
||||
|
||||
it ('keeps successful rows when another metadata request fails', async () => {
|
||||
api.apiGet
|
||||
.mockResolvedValueOnce (metadata ('https://example.com/one', 'first'))
|
||||
.mockRejectedValueOnce (new TypeError ('network'))
|
||||
|
||||
renderReviewPage (['https://example.com/one', 'https://example.com/two'])
|
||||
|
||||
expect (await screen.findAllByText ('first')).toHaveLength (2)
|
||||
await waitFor (() => {
|
||||
expect (screen.getAllByText ('登録不可')).toHaveLength (2)
|
||||
})
|
||||
expect (screen.getAllByRole ('button', { name: '編輯' })[0]).toBeEnabled ()
|
||||
})
|
||||
|
||||
it ('shows existing posts with Active Storage thumbnail precedence', async () => {
|
||||
api.apiGet.mockResolvedValue ({
|
||||
...metadata ('https://example.com/post', ''),
|
||||
existingPostId: 24,
|
||||
existingPost: {
|
||||
id: 24,
|
||||
title: 'existing post',
|
||||
url: 'https://example.com/post',
|
||||
thumbnail: 'https://example.com/storage.jpg',
|
||||
thumbnailBase: 'https://example.com/base.jpg' } })
|
||||
|
||||
renderReviewPage (['https://example.com/post'])
|
||||
|
||||
const disclosure = await screen.findByRole ('button', {
|
||||
name: '既存投稿による自動スキップ 1件' })
|
||||
expect (screen.getByRole ('button', { name: '追加' })).toBeDisabled ()
|
||||
|
||||
fireEvent.click (disclosure)
|
||||
|
||||
expect ((await screen.findAllByRole ('img', { name: 'サムネール' }))[0])
|
||||
.toHaveAttribute ('src', 'https://example.com/storage.jpg')
|
||||
})
|
||||
|
||||
it ('keeps a recoverable bulk failure on the review screen', async () => {
|
||||
api.apiGet.mockResolvedValue (
|
||||
metadata ('https://example.com/post', 'new post'))
|
||||
api.apiPost.mockResolvedValue ({
|
||||
results: [{
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: { title: ['invalid title'] } }] })
|
||||
|
||||
renderReviewPage (['https://example.com/post'])
|
||||
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
|
||||
|
||||
expect (await screen.findAllByText ('登録失敗')).toHaveLength (2)
|
||||
expect (screen.getAllByText ('invalid title')).toHaveLength (2)
|
||||
expect (screen.getAllByRole ('button', { name: '編輯' })[0]).toBeEnabled ()
|
||||
expect (screen.getAllByRole ('button', { name: '再試行' })[0]).toBeEnabled ()
|
||||
expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts/new?')
|
||||
})
|
||||
|
||||
it ('navigates to /posts after every submitted row is created', async () => {
|
||||
api.apiGet.mockResolvedValue (
|
||||
metadata ('https://example.com/post', 'new post'))
|
||||
api.apiPost.mockResolvedValue ({
|
||||
results: [{ status: 'created', post: { id: 42 } }] })
|
||||
|
||||
renderReviewPage (['https://example.com/post'])
|
||||
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
|
||||
expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts')
|
||||
})
|
||||
})
|
||||
|
||||
it ('restores the current work on review reload', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'restored row' } })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
|
||||
expect (await screen.findByText ('restored row')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('does not expose メタデータ to the user', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
fieldWarnings: { title: ['自動取得に失敗しました.'] } })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
expect (screen.queryByText (/メタデータ/)).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('returns to /posts/new while keeping the current work', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'restored row' } })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (await screen.findByRole ('button', { name: 'URL リスト入力へ戻る' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
|
||||
})
|
||||
expect (loadPostImportSession ()?.rows[0]?.attributes.title).toBe ('restored row')
|
||||
})
|
||||
|
||||
it ('disables row editing and navigation while batch import is running', async () => {
|
||||
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
api.apiPost.mockImplementationOnce (() =>
|
||||
new Promise<{ rows: PostImportRow[] }> (resolve => {
|
||||
resolveValidation = resolve
|
||||
}))
|
||||
api.apiPost.mockResolvedValueOnce ({
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
rows: [] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByRole ('button', { name: '編輯' })).toBeDisabled ()
|
||||
expect (screen.getByRole ('button', { name: 'URL リスト入力へ戻る' })).toBeDisabled ()
|
||||
expect (screen.getByRole ('button', { name: '取込実行' })).toBeDisabled ()
|
||||
})
|
||||
|
||||
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
})
|
||||
|
||||
it ('does not call import when validation omits a requested source row', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post-1\nhttps://example.com/post-2',
|
||||
repairMode: 'all',
|
||||
rows: [
|
||||
buildPostImportRow ({ sourceRow: 1 }),
|
||||
buildPostImportRow ({ sourceRow: 2 })] })
|
||||
api.apiPost.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (toastApi.toast).toHaveBeenCalledWith (
|
||||
expect.objectContaining ({ title: '再検証結果が不完全でした' }))
|
||||
})
|
||||
expect (api.apiPost).toHaveBeenCalledTimes (1)
|
||||
})
|
||||
|
||||
it ('navigates to /posts when all rows finish as created or skipped', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
rows: [{
|
||||
sourceRow: 1,
|
||||
status: 'created',
|
||||
post: { id: 1 } }] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
|
||||
})
|
||||
expect (loadPostImportSession ()).toBeNull ()
|
||||
})
|
||||
|
||||
it ('stays on review when a recoverable failed row remains', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
failed: 1,
|
||||
rows: [{
|
||||
sourceRow: 1,
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: { title: ['invalid'] } }] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
expect (await screen.findByText ('invalid')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('POSTS ROUTE')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('keeps edited recoverable rows in session before the next batch submit', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'failed',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'old title', duration: '2' },
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })] })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'edited title', duration: '2' },
|
||||
importStatus: 'pending',
|
||||
recoverable: true })] })
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'edited title', duration: '2' },
|
||||
importStatus: 'pending',
|
||||
recoverable: true })] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
failed: 1,
|
||||
rows: [{
|
||||
sourceRow: 1,
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: { title: ['invalid'] } }] })
|
||||
dialogue.form.mockImplementationOnce (async options => {
|
||||
let actions: DialogueFormAction[] = []
|
||||
const controls: DialogueFormControls = {
|
||||
close: vi.fn (),
|
||||
confirm: vi.fn (),
|
||||
setActions: next => {
|
||||
actions = next
|
||||
} }
|
||||
|
||||
render (options.body (controls))
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
fireEvent.change (screen.getByDisplayValue ('old title'), {
|
||||
target: { value: 'edited title' } })
|
||||
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||
})
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
|
||||
|
||||
await waitFor (() => {
|
||||
const saved = loadPostImportSession ()
|
||||
expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
|
||||
expect (saved?.rows[0]?.importStatus).toBe ('pending')
|
||||
})
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.title)
|
||||
.toBe ('edited title')
|
||||
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.duration)
|
||||
.toBe ('2')
|
||||
})
|
||||
})
|
||||
|
||||
it ('retries only failed rows and does not resend created or skipped rows', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'failed',
|
||||
rows: [
|
||||
buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'created',
|
||||
createdPostId: 1 }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
importStatus: 'skipped',
|
||||
skipReason: 'existing',
|
||||
existingPostId: 2 }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 3,
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })] })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 3,
|
||||
importStatus: 'pending',
|
||||
recoverable: true })] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 0,
|
||||
skipped: 1,
|
||||
failed: 0,
|
||||
rows: [{
|
||||
sourceRow: 3,
|
||||
status: 'skipped',
|
||||
existingPostId: 9 }] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (api.apiPost.mock.calls[1]?.[1]?.rows).toEqual ([
|
||||
expect.objectContaining ({ sourceRow: 3 })])
|
||||
})
|
||||
})
|
||||
|
||||
it ('allows ready rows to be manually skipped and excludes them from API payloads', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
|
||||
})
|
||||
expect (api.apiPost).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('restores values and errors when manual skip is cleared', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
status: 'error',
|
||||
attributes: { title: 'kept title' },
|
||||
validationErrors: { title: ['invalid'] } })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
const toggle = await screen.findByRole ('checkbox', { name: 'スキップ' })
|
||||
fireEvent.click (toggle)
|
||||
fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' }))
|
||||
|
||||
expect (screen.getByText ('kept title')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('invalid')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('moves existing skipped rows into the collapsed section', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [
|
||||
buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'existing row' },
|
||||
skipReason: 'existing',
|
||||
existingPostId: 2 }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
attributes: { title: 'normal row' } })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
expect (screen.getByText ('既存投稿による自動スキップ 1件')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('existing row')).not.toBeInTheDocument ()
|
||||
expect (screen.getByText ('normal row')).toBeInTheDocument ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '既存投稿による自動スキップ 1件' }))
|
||||
|
||||
expect (screen.getByText ('existing row')).toBeInTheDocument ()
|
||||
expect (screen.queryAllByRole ('button', { name: '編輯' })).toHaveLength (1)
|
||||
expect (screen.queryAllByRole ('checkbox', { name: 'スキップ' })).toHaveLength (1)
|
||||
})
|
||||
|
||||
it ('navigates to /posts when the last failed row completes', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'failed',
|
||||
rows: [
|
||||
buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'created',
|
||||
createdPostId: 1 }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })] })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
importStatus: 'pending',
|
||||
recoverable: true })] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
rows: [{
|
||||
sourceRow: 2,
|
||||
status: 'created',
|
||||
post: { id: 2 } }] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
|
||||
it ('sorts recoverable pending validation rows to the top in repair mode', () => {
|
||||
savePostImportSession ({
|
||||
source: '',
|
||||
repairMode: 'failed',
|
||||
rows: [
|
||||
buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
attributes: { title: 'normal row' } }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'repair row' },
|
||||
importStatus: 'pending',
|
||||
recoverable: true,
|
||||
validationErrors: { title: ['invalid'] } })] })
|
||||
|
||||
const { container } = renderReviewPage ()
|
||||
|
||||
const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map (
|
||||
node => node.textContent)
|
||||
expect (titles[0]).toBe ('repair row')
|
||||
expect (api.apiPost).toHaveBeenCalledWith ('/posts/bulk', expect.any (FormData))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -169,16 +169,17 @@ const buildInitialRows = (urls: string[]): PostImportRow[] => {
|
||||
urls.map ((url, index) => {
|
||||
const sourceRow = index + 1
|
||||
const rowIssues = issuesByRow[sourceRow] ?? []
|
||||
const validationErrors: Record<string, string[]> =
|
||||
rowIssues.length > 0
|
||||
? { url: [...rowIssues] }
|
||||
: { }
|
||||
return {
|
||||
sourceRow,
|
||||
url,
|
||||
attributes: emptyAttributes (),
|
||||
fieldWarnings: { },
|
||||
baseWarnings: [],
|
||||
validationErrors:
|
||||
rowIssues.length > 0
|
||||
? { url: [...rowIssues] }
|
||||
: { },
|
||||
validationErrors,
|
||||
provenance: emptyProvenance (),
|
||||
tagSources: emptyTagSources (),
|
||||
status: rowIssues.length > 0 ? 'error' : 'pending',
|
||||
@@ -481,7 +482,7 @@ const mergePreviewRow = (
|
||||
nextRow.attributes.tags = preview.tags ?? ''
|
||||
nextRow.displayTags = cloneDisplayTags (preview.displayTags)
|
||||
}
|
||||
nextRow.tagSources.automatic = preview.tags ?? ''
|
||||
nextRow.tagSources!.automatic = preview.tags ?? ''
|
||||
if (currentRow.provenance.parentPostIds !== 'manual')
|
||||
nextRow.attributes.parentPostIds = String (preview.parentPostIds ?? '')
|
||||
if (currentRow.provenance.duration !== 'manual')
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiPost: vi.fn (),
|
||||
isApiError: vi.fn () }))
|
||||
|
||||
const router = vi.hoisted (() => ({ navigate: vi.fn () }))
|
||||
|
||||
const toastApi = vi.hoisted (() => ({ toast: vi.fn () }))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
vi.mock ('react-router-dom', async importOriginal => ({
|
||||
...await importOriginal<typeof import('react-router-dom')> (),
|
||||
useNavigate: () => router.navigate }))
|
||||
@@ -24,71 +15,53 @@ describe ('PostImportSourcePage', () => {
|
||||
beforeEach (() => {
|
||||
sessionStorage.clear ()
|
||||
vi.clearAllMocks ()
|
||||
api.isApiError.mockReturnValue (false)
|
||||
})
|
||||
|
||||
it ('shows no empty error initially and validates only after Next is pressed', () => {
|
||||
it ('validates an empty source only after Next is pressed', () => {
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
|
||||
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||
|
||||
expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument ()
|
||||
expect (api.apiPost).not.toHaveBeenCalled ()
|
||||
expect (router.navigate).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('shows frontend URL issues with line numbers without requesting preview', () => {
|
||||
it ('reports frontend URL issues with original line numbers', () => {
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
const input = screen.getByRole ('textbox', { name: '' })
|
||||
|
||||
fireEvent.change (input, {
|
||||
target: { value: '\nftp://example.com/file\nhttps://example.com/valid' } })
|
||||
expect (screen.queryByText (/2 行目/)).not.toBeInTheDocument ()
|
||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||
|
||||
expect (screen.getByText (/2 行目: HTTP または HTTPS/)).toBeInTheDocument ()
|
||||
expect (screen.getByText ('ftp://example.com/file')).toBeInTheDocument ()
|
||||
expect (input).toHaveAttribute ('aria-invalid', 'true')
|
||||
expect (input.getAttribute ('aria-describedby')).toContain ('post-import-source-issues')
|
||||
expect (api.apiPost).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('shows backend URL errors against original input without a session', async () => {
|
||||
api.apiPost.mockResolvedValue ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
url: 'https://example.com/canonical',
|
||||
status: 'error',
|
||||
validationErrors: { url: ['URL が重複しています.'] } })] })
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
|
||||
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
|
||||
target: { value: '\nhttps://example.com/original' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||
|
||||
expect (await screen.findByText ('2 行目: URL が重複しています.')).toBeInTheDocument ()
|
||||
expect (screen.getAllByText ('https://example.com/original')).toHaveLength (2)
|
||||
expect (router.navigate).not.toHaveBeenCalled ()
|
||||
expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
|
||||
sessionStorage.key (index))).not.toContainEqual(
|
||||
expect.stringMatching (/^post-import-session:/))
|
||||
})
|
||||
|
||||
it ('navigates to /posts/new with query state after a successful preview', async () => {
|
||||
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ()] })
|
||||
it ('navigates with individually encoded URLs without calling an API', () => {
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
|
||||
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
|
||||
target: { value: 'https://example.com/post' } })
|
||||
target: {
|
||||
value: 'https://example.com/one+a\nhttps://example.com/two?value=b+c' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (router.navigate).toHaveBeenCalledWith (
|
||||
expect.stringMatching (/^\/posts\/new\?/))
|
||||
})
|
||||
expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
|
||||
sessionStorage.key (index))).not.toContainEqual(
|
||||
expect.stringMatching (/^post-import-session:/))
|
||||
expect (router.navigate).toHaveBeenCalledWith (
|
||||
'/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
|
||||
+ '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc')
|
||||
})
|
||||
|
||||
it ('disables Next when the encoded request target reaches 4096 bytes', () => {
|
||||
renderWithProviders (<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 ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PostNewPage from '@/pages/posts/PostNewPage'
|
||||
@@ -7,6 +6,7 @@ import { buildUser } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
apiPost: vi.fn (),
|
||||
isApiError: vi.fn () }))
|
||||
|
||||
@@ -16,27 +16,23 @@ describe ('PostNewPage', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
api.isApiError.mockReturnValue (false)
|
||||
api.apiGet.mockResolvedValue ({
|
||||
url: 'https://example.com/post',
|
||||
title: 'post',
|
||||
tags: '' })
|
||||
sessionStorage.clear ()
|
||||
})
|
||||
|
||||
it ('shows the source page on /posts/new', () => {
|
||||
renderWithProviders (
|
||||
<MemoryRouter initialEntries={['/posts/new']}>
|
||||
<Routes>
|
||||
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>)
|
||||
renderWithProviders (<PostNewPage user={buildUser ()}/>, {
|
||||
route: '/posts/new' })
|
||||
|
||||
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('shows the review page when query state is present', () => {
|
||||
renderWithProviders (
|
||||
<MemoryRouter initialEntries={['/posts/new?url=https%3A%2F%2Fexample.com%2Fpost']}>
|
||||
<Routes>
|
||||
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>)
|
||||
renderWithProviders (<PostNewPage user={buildUser ()}/>, {
|
||||
route: '/posts/new?urls=https%3A%2F%2Fexample.com%2Fpost' })
|
||||
|
||||
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
|
||||
|
||||
@@ -6,7 +6,6 @@ import { dateString } from '@/lib/utils'
|
||||
import { buildTag, buildUser } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { NicoTag } from '@/types'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
@@ -27,10 +26,10 @@ const scrollIntoView = vi.fn ()
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
useDialogue: () => dialogue,
|
||||
}))
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
|
||||
...await importOriginal<
|
||||
typeof import('@/components/dialogues/DialogueProvider')> (),
|
||||
useDialogue: () => dialogue }))
|
||||
|
||||
const buildNicoTag = (values: Partial<NicoTag> = {}): NicoTag => ({
|
||||
...buildTag (),
|
||||
|
||||
@@ -40,10 +40,10 @@ const postEmbed = vi.hoisted (() => ({
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/lib/posts', () => postsApi)
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
useDialogue: () => dialogue,
|
||||
}))
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
|
||||
...await importOriginal<
|
||||
typeof import('@/components/dialogues/DialogueProvider')> (),
|
||||
useDialogue: () => dialogue }))
|
||||
vi.mock ('@/components/PostEmbed', () => ({
|
||||
default: (props: {
|
||||
ref?: { current: unknown }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
import type { PostImportRow } from '@/lib/postImportTypes'
|
||||
|
||||
export const buildPostImportRow = (
|
||||
overrides: Partial<PostImportRow> = {},
|
||||
|
||||
+33
-15
@@ -1,7 +1,8 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render } from '@testing-library/react'
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom'
|
||||
|
||||
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
||||
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
|
||||
@@ -13,6 +14,20 @@ type Options = {
|
||||
route?: string
|
||||
}
|
||||
|
||||
const TestContentContext = createContext<ReactNode> (null)
|
||||
|
||||
|
||||
const TestRoute = () => {
|
||||
const children = useContext (TestContentContext)
|
||||
|
||||
return (
|
||||
<UnsavedChangesGuardProvider>
|
||||
<KeyboardShortcutsProvider>
|
||||
{children}
|
||||
</KeyboardShortcutsProvider>
|
||||
</UnsavedChangesGuardProvider>)
|
||||
}
|
||||
|
||||
export const renderWithProviders = (
|
||||
ui: ReactElement,
|
||||
options: Options = {},
|
||||
@@ -22,20 +37,23 @@ export const renderWithProviders = (
|
||||
queries: { retry: false } },
|
||||
})
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={[options.route ?? '/']}>
|
||||
<DialogueProvider>
|
||||
<UnsavedChangesGuardProvider>
|
||||
<KeyboardShortcutsProvider>
|
||||
{children}
|
||||
</KeyboardShortcutsProvider>
|
||||
</UnsavedChangesGuardProvider>
|
||||
</DialogueProvider>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>
|
||||
</QueryClientProvider>)
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => {
|
||||
const [router] = useState (() => createMemoryRouter ([{
|
||||
path: '*',
|
||||
element: <TestRoute/> }], {
|
||||
initialEntries: [options.route ?? '/'] }))
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HelmetProvider>
|
||||
<DialogueProvider>
|
||||
<TestContentContext.Provider value={children}>
|
||||
<RouterProvider router={router}/>
|
||||
</TestContentContext.Provider>
|
||||
</DialogueProvider>
|
||||
</HelmetProvider>
|
||||
</QueryClientProvider>)
|
||||
}
|
||||
|
||||
return {
|
||||
queryClient,
|
||||
|
||||
新しい課題から参照
ユーザをブロックする