広場投稿追加画面の刷新 (#399) (#413)

Reviewed-on: #413
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #413 でマージされました.
このコミットが含まれているのは:
2026-07-19 00:03:10 +09:00
committed by みてるぞ
コミット f1181e8510
99個のファイルの変更10242行の追加1126行の削除
+262 -29
ファイルの表示
@@ -9,11 +9,11 @@ RSpec.describe 'Posts API', type: :request do
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
before do
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
allow(Post).to receive(:resized_thumbnail_attachment).and_return(
io: StringIO.new('dummy'),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg'
)
allow(Post).to receive(:resized_thumbnail_attachment) do
{ io: StringIO.new('dummy'),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg' }
end
end
def create_nico_tag!(name)
@@ -21,8 +21,7 @@ RSpec.describe 'Posts API', type: :request do
end
def dummy_upload
# 中身は何でもいい(加工処理はスタブしてる)
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
real_thumbnail_upload
end
def real_thumbnail_upload
@@ -102,34 +101,34 @@ RSpec.describe 'Posts API', type: :request do
end
end
describe "GET /posts" do
describe 'GET /posts' do
let!(:user) { create_member_user! }
let!(:tag_name) { TagName.create!(name: "spec_tag") }
let!(:tag_name) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name:, category: :general) }
let!(:tag_name2) { TagName.create!(name: 'unko') }
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) }
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
let!(:hit_post) do
Post.create!(uploaded_user: user, title: "hello spec world",
Post.create!(uploaded_user: user, title: 'hello spec world',
url: 'https://example.com/spec2').tap do |p|
PostTag.create!(post: p, tag:)
end
end
let!(:miss_post) do
Post.create!(uploaded_user: user, title: "unrelated title",
Post.create!(uploaded_user: user, title: 'unrelated title',
url: 'https://example.com/spec3').tap do |p|
PostTag.create!(post: p, tag: tag2)
end
end
it "returns posts with tag name in JSON" do
get "/posts"
it 'returns posts with tag name in JSON' do
get '/posts'
expect(response).to have_http_status(:ok)
posts = json.fetch("posts")
posts = json.fetch('posts')
# 全postの全tagが name を含むこと
expect(posts).not_to be_empty
@@ -142,8 +141,8 @@ RSpec.describe 'Posts API', type: :request do
expect(json['count']).to be_an(Integer)
# spec_tag を含む投稿が存在すること
all_tag_names = posts.flat_map { |p| p["tags"].map { |t| t["name"] } }
expect(all_tag_names).to include("spec_tag")
all_tag_names = posts.flat_map { |p| p['tags'].map { |t| t['name'] } }
expect(all_tag_names).to include('spec_tag')
end
it 'keeps children and sections keys in non-detail tag responses' do
@@ -162,9 +161,9 @@ RSpec.describe 'Posts API', type: :request do
])
end
context "when q is provided" do
it "filters posts by q (hit case)" do
get "/posts", params: { tags: "spec_tag" }
context 'when q is provided' do
it 'filters posts by q (hit case)' do
get '/posts', params: { tags: 'spec_tag' }
expect(response).to have_http_status(:ok)
posts = json.fetch('posts')
@@ -182,8 +181,8 @@ RSpec.describe 'Posts API', type: :request do
end
end
it "filters posts by q (hit case by alias)" do
get "/posts", params: { tags: "manko" }
it 'filters posts by q (hit case by alias)' do
get '/posts', params: { tags: 'manko' }
expect(response).to have_http_status(:ok)
posts = json.fetch('posts')
@@ -201,11 +200,11 @@ RSpec.describe 'Posts API', type: :request do
end
end
it "returns empty posts when nothing matches" do
get "/posts", params: { tags: "no_such_keyword_12345" }
it 'returns empty posts when nothing matches' do
get '/posts', params: { tags: 'no_such_keyword_12345' }
expect(response).to have_http_status(:ok)
expect(json.fetch("posts")).to eq([])
expect(json.fetch('posts')).to eq([])
expect(json.fetch('count')).to eq(0)
end
end
@@ -693,6 +692,73 @@ RSpec.describe 'Posts API', type: :request do
end
end
describe 'GET /posts/metadata' do
let(:member) { create(:user, :member) }
it 'returns compact existing post data without fetching external metadata' do
sign_in_as(member)
existing = create(
:post,
title: 'existing post',
url: 'https://example.com/existing')
existing.thumbnail.attach(
io: StringIO.new('thumbnail'),
filename: 'thumbnail.jpg',
content_type: 'image/jpeg')
expect(PostMetadataFetcher).not_to receive(:fetch)
get '/posts/metadata', params: { url: 'https://example.com/existing' }
expect(response).to have_http_status(:ok)
expect(json).to include(
'url' => existing.url,
'existing_post_id' => existing.id,
'field_warnings' => { })
expect(json.fetch('existing_post')).to include(
'id' => existing.id,
'title' => existing.title,
'url' => existing.url,
'thumbnail_base' => existing.thumbnail_base)
expect(json.dig('existing_post', 'thumbnail'))
.to include('/rails/active_storage/blobs/proxy/')
end
it 'returns fetched metadata and structured display tags' do
sign_in_as(member)
allow(Preview::UrlSafety).to receive(:validate)
allow(PostMetadataFetcher).to receive(:fetch).and_return(
title: 'fetched title',
thumbnail_base: 'https://example.com/thumbnail.jpg',
tags: 'character:虹夏',
display_tags: [{ name: '虹夏', category: 'character' }],
original_created_from: nil,
original_created_before: nil,
duration: '1:00',
video_ms: 60_000)
get '/posts/metadata', params: { url: 'https://example.com/new' }
expect(response).to have_http_status(:ok)
expect(json).to include(
'url' => 'https://example.com/new',
'title' => 'fetched title',
'duration' => '1:00',
'video_ms' => 60_000,
'field_warnings' => { })
expect(json.fetch('display_tags')).to eq(
[{ 'name' => '虹夏', 'category' => 'character' }])
end
it 'returns URL validation errors as 422' do
sign_in_as(member)
get '/posts/metadata', params: { url: 'file:///etc/passwd' }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to have_key('url')
end
end
describe 'POST /posts' do
let(:member) { create(:user, :member) }
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
@@ -712,6 +778,46 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:forbidden)
end
it 'dry-runs without persisting posts or new tags' do
sign_in_as(member)
counts = [Post.count, Tag.count, TagName.count]
post '/posts?dry=1', params: post_write_params(
title: 'dry-run post',
url: 'https://example.com/dry-run',
tags: 'character:new_dry_run_tag')
expect(response).to have_http_status(:ok)
expect(json).to include(
'url' => 'https://example.com/dry-run',
'tags' => 'new_dry_run_tag',
'existing_post_id' => nil)
expect(json.fetch('display_tags')).to eq(
[{ 'name' => 'new_dry_run_tag',
'category' => 'character',
'section_literals' => [] }])
expect([Post.count, Tag.count, TagName.count]).to eq(counts)
end
it 'dry-runs an existing URL without validating its upload' do
sign_in_as(member)
existing = create(:post, url: 'https://example.com/dry-existing')
invalid_upload = Rack::Test::UploadedFile.new(
StringIO.new('<svg></svg>'),
'image/png',
original_filename: 'thumbnail.png')
post '/posts?dry=1', params: post_write_params(
title: '',
url: existing.url,
tags: '',
thumbnail: invalid_upload)
expect(response).to have_http_status(:ok)
expect(json).to include('existing_post_id' => existing.id)
expect(json.fetch('field_warnings')).not_to have_key('thumbnail_base')
end
it '201 and creates post + tags when member' do
sign_in_as(member)
@@ -769,7 +875,8 @@ RSpec.describe 'Posts API', type: :request do
)
expect(response).to have_http_status(:created)
expect(open_transactions).to eq([baseline_open_transactions])
expect(open_transactions).to eq(
[baseline_open_transactions, baseline_open_transactions])
end
it 'returns 422 and does not create a post when thumbnail resize fails' do
@@ -827,7 +934,7 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['廃止済みタグは付与できません.']
'tags' => ['廃止済みタグがあります: deprecated_direct_tag']
)
end
@@ -1054,7 +1161,7 @@ RSpec.describe 'Posts API', type: :request do
)
end
context "when nico tag already exists in tags" do
context 'when nico tag already exists in tags' do
before do
Tag.find_undiscard_or_create_by!(
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
@@ -1251,6 +1358,47 @@ RSpec.describe 'Posts API', type: :request do
end
end
describe 'POST /posts/bulk' do
let(:member) { create(:user, :member) }
it 'parses the manifest and indexed thumbnail parts' do
sign_in_as(member)
manifest = [{ 'url' => 'https://example.com/bulk',
'title' => 'bulk post',
'tags' => 'spec_tag',
'parent_post_ids' => '' }]
creator = instance_double(
PostBulkCreator,
run: { results: [{ status: 'created', post: { id: 123 } }] })
allow(PostBulkCreator).to receive(:new).and_return(creator)
post '/posts/bulk', params: {
posts: JSON.generate(manifest),
thumbnails: { '0' => real_thumbnail_upload } }
expect(response).to have_http_status(:ok)
expect(json.fetch('results')).to eq(
[{ 'status' => 'created', 'post' => { 'id' => 123 } }])
expect(PostBulkCreator).to have_received(:new) do |arguments|
expect(arguments[:actor]).to eq(member)
expect(arguments[:posts]).to eq(manifest)
expect(arguments[:thumbnails].keys).to eq([0])
expect(arguments[:host]).to eq('http://www.example.com')
end
end
it 'rejects malformed manifests as a request-level error' do
sign_in_as(member)
post '/posts/bulk', params: {
posts: '{',
thumbnails: { '0' => real_thumbnail_upload } }
expect(response).to have_http_status(:bad_request)
expect(json.fetch('message')).to eq('posts manifest の JSON が不正です.')
end
end
describe 'PUT /posts/:id' do
let(:member) { create(:user, :member) }
@@ -1307,7 +1455,7 @@ RSpec.describe 'Posts API', type: :request do
)
end
context "when nico tag already exists in tags" do
context 'when nico tag already exists in tags' do
before do
Tag.find_undiscard_or_create_by!(
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
@@ -1564,7 +1712,7 @@ RSpec.describe 'Posts API', type: :request do
expect(post_record.reload.title).to eq('updated by other user')
end
it 'returns 409 with mergeable true when stale tag changes do not conflict but merge is not requested' do
it 'returns mergeable 409 for stale non-conflicting tag changes without merge' do
sign_in_as(member)
base_version = create_post_version_for!(post_record.reload)
@@ -2206,6 +2354,91 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
end
it 'rejects second-bearing original created timestamps on POST /posts' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'invalid original created from',
url: 'https://example.com/invalid-original-created-from',
tags: 'spec_tag',
thumbnail: dummy_upload,
original_created_from: '2020-01-01T00:00:01Z')
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.']
)
expect(json.fetch('errors')).not_to have_key('original_created_at')
end
it 'rejects fractional-second original created timestamps on POST /posts' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'invalid original created before',
url: 'https://example.com/invalid-original-created-before',
tags: 'spec_tag',
thumbnail: dummy_upload,
original_created_before: '2020-01-01T00:00:00.123Z')
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'original_created_before' => ['オリジナルの作成日時は分単位で入力してください.']
)
expect(json.fetch('errors')).not_to have_key('original_created_at')
end
it 'rejects non-increasing original created ranges on POST /posts' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'non-increasing original created range',
url: 'https://example.com/non-increasing-original-created-range',
tags: 'spec_tag',
thumbnail: dummy_upload,
original_created_from: '2020-01-01T00:00Z',
original_created_before: '2020-01-01T00:00Z')
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'original_created_at' => ['オリジナルの作成日時の順番がをかしぃです.']
)
end
it 'accepts original created ranges that are exactly one minute on POST /posts' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'valid original created range',
url: 'https://example.com/valid-original-created-range',
tags: 'spec_tag',
thumbnail: dummy_upload,
original_created_from: '2020-01-01T00:00Z',
original_created_before: '2020-01-01T00:01Z')
expect(response).to have_http_status(:created)
expect(Time.iso8601(json.fetch('original_created_from')))
.to eq(Time.utc(2020, 1, 1, 0, 0))
expect(Time.iso8601(json.fetch('original_created_before')))
.to eq(Time.utc(2020, 1, 1, 0, 1))
end
it 'rejects unparseable original created timestamps on PUT /posts/:id' do
sign_in_as(member)
base_version = create_post_version_for!(post_record)
put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no,
title: 'updated title',
tags: 'spec_tag',
original_created_from: 'not-a-time')
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'original_created_from' => ['オリジナルの作成日時の形式が不正です.']
)
end
end
describe 'tag versioning from post write actions' do