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