**本番 DB のバックアップをかならずすること** Reviewed-on: #420 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #420 でマージされました。
このコミットが含まれているのは:
@@ -3,10 +3,34 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe 'NicoTags', type: :request do
|
||||
describe 'GET /tags/nico' do
|
||||
it 'returns paginated tags and total count' do
|
||||
create_list(:tag, 3, :nico)
|
||||
it 'returns the legacy Tag-compatible external fields' do
|
||||
external = create(:external_tag, name: 'legacy_external', post_count: 3)
|
||||
|
||||
get '/tags/nico', params: { page: 2, limit: 2 }
|
||||
get '/tags/nico', params: { name: 'legacy_external' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(1)
|
||||
expect(json.fetch('tags')).to contain_exactly(
|
||||
a_hash_including(
|
||||
'id' => external.id,
|
||||
'name' => 'nico:legacy_external',
|
||||
'category' => 'nico',
|
||||
'post_count' => 3,
|
||||
'created_at' => external.created_at.as_json,
|
||||
'updated_at' => external.created_at.as_json,
|
||||
'deprecated_at' => nil,
|
||||
'aliases' => [],
|
||||
'parents' => [],
|
||||
'has_wiki' => false,
|
||||
'material_id' => nil,
|
||||
'has_deerjikists' => false,
|
||||
'linked_tags' => []))
|
||||
end
|
||||
|
||||
it 'returns paginated tags and total count' do
|
||||
3.times { |i| create(:external_tag, name: "pagination_#{ i }") }
|
||||
|
||||
get '/tags/nico', params: { page: 2, limit: 2, name: 'pagination_' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json['tags'].size).to eq(1)
|
||||
@@ -14,12 +38,12 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
end
|
||||
|
||||
it 'filters by nico tag name, linked tag name, and link status' do
|
||||
linked = create(:tag, :nico)
|
||||
linked.tag_name.update!(name: 'nico:search_linked')
|
||||
unlinked = create(:tag, :nico)
|
||||
unlinked.tag_name.update!(name: 'nico:search_unlinked')
|
||||
other = create(:tag, :nico)
|
||||
other.tag_name.update!(name: 'nico:other')
|
||||
linked = create(:external_tag)
|
||||
linked.update!(name: 'search_linked')
|
||||
unlinked = create(:external_tag)
|
||||
unlinked.update!(name: 'search_unlinked')
|
||||
other = create(:external_tag)
|
||||
other.update!(name: 'other')
|
||||
destination = create(:tag, :general)
|
||||
destination.tag_name.update!(name: 'destination_search')
|
||||
NicoTagRelation.create!(nico_tag: linked, tag: destination)
|
||||
@@ -41,37 +65,77 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id])
|
||||
end
|
||||
|
||||
it 'filters by the qualified legacy name as well as the raw name' do
|
||||
external = create(:external_tag, name: 'qualified_filter')
|
||||
create(:external_tag, name: 'unrelated_filter')
|
||||
|
||||
['qualified_filter', 'nico:qualified_filter'].each do |name|
|
||||
get '/tags/nico', params: { name: }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(1)
|
||||
expect(json.fetch('tags')).to contain_exactly(
|
||||
a_hash_including('id' => external.id, 'name' => 'nico:qualified_filter'))
|
||||
end
|
||||
end
|
||||
|
||||
it 'sorts by name and timestamps' do
|
||||
older = create(:tag, :nico)
|
||||
older.tag_name.update!(name: 'nico:a')
|
||||
older = create(:external_tag)
|
||||
older.update!(name: 'ordered_a')
|
||||
older.update_columns(created_at: 2.days.ago)
|
||||
newer = create(:tag, :nico)
|
||||
newer.tag_name.update!(name: 'nico:b')
|
||||
newer = create(:external_tag)
|
||||
newer.update!(name: 'ordered_b')
|
||||
newer.update_columns(created_at: 1.day.ago)
|
||||
older_post_tag =
|
||||
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-older'), tag: older)
|
||||
PostExternalTag.create!(post: create(:post), external_tag: older)
|
||||
older_post_tag.update_columns(created_at: 1.hour.ago)
|
||||
newer_post_tag =
|
||||
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-newer'), tag: newer)
|
||||
PostExternalTag.create!(post: create(:post), external_tag: newer)
|
||||
newer_post_tag.update_columns(created_at: 2.hours.ago)
|
||||
|
||||
get '/tags/nico', params: { order: 'name:desc' }
|
||||
get '/tags/nico', params: { order: 'name:desc', name: 'ordered_' }
|
||||
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([newer.id, older.id])
|
||||
|
||||
get '/tags/nico', params: { order: 'created_at:asc' }
|
||||
get '/tags/nico', params: { order: 'created_at:asc', name: 'ordered_' }
|
||||
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
|
||||
|
||||
get '/tags/nico', params: { order: 'updated_at:desc' }
|
||||
get '/tags/nico', params: { order: 'updated_at:desc', name: 'ordered_' }
|
||||
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
|
||||
expect(Time.zone.parse(json.fetch('tags').first.fetch('recent_post_tag_created_at')))
|
||||
.to be_within(1.second).of(older_post_tag.created_at)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /tags/nico/:id' do
|
||||
it 'returns the external tag even when an internal tag has the same id' do
|
||||
internal = create(:tag)
|
||||
external = create(
|
||||
:external_tag,
|
||||
id: internal.id,
|
||||
name: 'external_detail')
|
||||
|
||||
get "/tags/nico/#{ external.id }"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'id' => external.id,
|
||||
'name' => 'nico:external_detail',
|
||||
'category' => 'nico')
|
||||
end
|
||||
|
||||
it 'returns 404 when the external tag does not exist' do
|
||||
internal = create(:tag)
|
||||
|
||||
get "/tags/nico/#{ internal.id }"
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /tags/nico/:id' do
|
||||
let(:member) { create(:user, :member) }
|
||||
let(:admin) { create(:user, :admin) }
|
||||
let(:nico_tag) { create(:tag, :nico) }
|
||||
let(:nico_tag) { create(:external_tag) }
|
||||
|
||||
it '401 when not logged in' do
|
||||
sign_out
|
||||
@@ -85,18 +149,18 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
|
||||
it '400 when target is not nico category' do
|
||||
it '404 when only an internal tag exists for the target id' do
|
||||
sign_in_as(member)
|
||||
non_nico = create(:tag, :general)
|
||||
expect(ExternalTag.exists?(non_nico.id)).to be(false)
|
||||
patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' }
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it '200 and updates linked tags while recording tag versions' do
|
||||
sign_in_as(admin)
|
||||
|
||||
nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source')
|
||||
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
|
||||
nico_tag = create(:external_tag, name: 'nico_tags_spec_source')
|
||||
|
||||
linked_a_name = TagName.create!(name: 'nico_linked_a')
|
||||
linked_a = Tag.create!(tag_name: linked_a_name, category: :general)
|
||||
@@ -104,7 +168,8 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
linked_b_name = TagName.create!(name: 'nico_linked_b')
|
||||
linked_b = Tag.create!(tag_name: linked_b_name, category: :general)
|
||||
|
||||
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: admin)
|
||||
NicoTagVersionRecorder.record!(external_tag: nico_tag,
|
||||
event_type: :create, created_by_user: admin)
|
||||
|
||||
expect {
|
||||
patch "/tags/nico/#{nico_tag.id}", params: {
|
||||
@@ -126,26 +191,26 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
expect(versions.map(&:event_type)).to eq(['create', 'update'])
|
||||
expect(versions.last.linked_tags.split).to match_array([
|
||||
'nico_linked_a',
|
||||
'nico_linked_b'
|
||||
])
|
||||
'nico_linked_b'])
|
||||
expect(versions.last.created_by_user_id).to eq(admin.id)
|
||||
end
|
||||
|
||||
it 'returns 422 when linked tag normalises to nico tag' do
|
||||
it 'clears existing links and records the empty mapping for a member' do
|
||||
sign_in_as(member)
|
||||
|
||||
other_nico = create(:tag, :nico, name: 'nico:linked_ng')
|
||||
TagName.create!(name: 'linked_ng_alias', canonical: other_nico.tag_name)
|
||||
|
||||
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: member)
|
||||
linked = create(:tag)
|
||||
NicoTagRelation.insert_all!([{ nico_tag_id: nico_tag.id, tag_id: linked.id }])
|
||||
NicoTagVersionRecorder.record!(external_tag: nico_tag,
|
||||
event_type: :create, created_by_user: member)
|
||||
|
||||
expect {
|
||||
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
|
||||
}.not_to change(NicoTagVersion, :count)
|
||||
patch "/tags/nico/#{ nico_tag.id }", params: { tags: '' }
|
||||
}.to change(NicoTagVersion, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'tags' => ['ニコニコ・タグ同士は連携できません.'])
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to eq([])
|
||||
expect(nico_tag.reload.linked_tags).to be_empty
|
||||
expect(nico_tag.nico_tag_versions.order(:version_no).last)
|
||||
.to have_attributes(linked_tags: '', created_by_user: member)
|
||||
end
|
||||
|
||||
it 'returns the tags field error when a nico tag is specified directly' do
|
||||
|
||||
@@ -17,7 +17,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
end
|
||||
|
||||
def create_nico_tag!(name)
|
||||
Tag.find_or_create_by_tag_name!(name, category: :nico)
|
||||
ExternalTag.find_or_create_by!(platform: :nico, name: name.delete_prefix('nico:'))
|
||||
end
|
||||
|
||||
def dummy_upload
|
||||
@@ -93,6 +93,26 @@ RSpec.describe 'Posts API', type: :request do
|
||||
count
|
||||
end
|
||||
|
||||
def expect_external_tag_json tag_json, external_tag
|
||||
external_tag.reload
|
||||
|
||||
expect(tag_json).to include(
|
||||
'id' => external_tag.id,
|
||||
'name' => "#{ external_tag.platform }:#{ external_tag.name }",
|
||||
'category' => 'nico',
|
||||
'created_at' => external_tag.created_at.as_json,
|
||||
'updated_at' => external_tag.created_at.as_json,
|
||||
'deprecated_at' => nil,
|
||||
'aliases' => [],
|
||||
'parents' => [],
|
||||
'post_count' => external_tag.post_count,
|
||||
'has_wiki' => false,
|
||||
'material_id' => nil,
|
||||
'has_deerjikists' => false,
|
||||
'children' => [],
|
||||
'sections' => [])
|
||||
end
|
||||
|
||||
let!(:tag_name) { TagName.create!(name: 'spec_tag') }
|
||||
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }
|
||||
|
||||
@@ -233,6 +253,93 @@ RSpec.describe 'Posts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'with legacy external tag name searches' do
|
||||
let!(:external_tag) { create(:external_tag, id: tag.id, name: 'search_external') }
|
||||
let!(:both_post) do
|
||||
create(:post).tap do |post|
|
||||
PostTag.create!(post:, tag:)
|
||||
PostExternalTag.create!(post:, external_tag:)
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
PostExternalTag.create!(post: miss_post, external_tag:)
|
||||
end
|
||||
|
||||
it 'keeps internal name searches independent of colliding external ids' do
|
||||
get '/posts', params: { tags: tag.name }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(3)
|
||||
expect(json.fetch('posts').map { _1.fetch('id') })
|
||||
.to contain_exactly(post_record.id, hit_post.id, both_post.id)
|
||||
end
|
||||
|
||||
it 'finds posts through PostExternalTag by the qualified legacy name' do
|
||||
get '/posts', params: { tags: 'nico:search_external' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(2)
|
||||
expect(json.fetch('posts').map { _1.fetch('id') })
|
||||
.to contain_exactly(miss_post.id, both_post.id)
|
||||
end
|
||||
|
||||
[nil, 'all'].each do |match|
|
||||
it "intersects internal and external matches with match=#{ match || 'omitted' }" do
|
||||
params = { tags: "#{ tag.name } nico:search_external" }
|
||||
params[:match] = match if match
|
||||
|
||||
get '/posts', params: params
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(1)
|
||||
expect(json.fetch('posts').map { _1.fetch('id') }).to eq([both_post.id])
|
||||
end
|
||||
end
|
||||
|
||||
it 'unions internal alias and external matches without duplicate posts' do
|
||||
get '/posts', params: { tags: 'manko nico:search_external', match: 'any' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(4)
|
||||
expect(json.fetch('posts').map { _1.fetch('id') })
|
||||
.to contain_exactly(post_record.id, hit_post.id, miss_post.id, both_post.id)
|
||||
end
|
||||
|
||||
it 'excludes external matches from an internal tag search' do
|
||||
get '/posts', params: { tags: "#{ tag.name } not:nico:search_external" }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(2)
|
||||
expect(json.fetch('posts').map { _1.fetch('id') })
|
||||
.to contain_exactly(post_record.id, hit_post.id)
|
||||
end
|
||||
|
||||
it 'unions an external match with a negated internal match' do
|
||||
get '/posts', params: { tags: "nico:search_external not:#{ tag.name }", match: 'any' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(2)
|
||||
expect(json.fetch('posts').map { _1.fetch('id') })
|
||||
.to contain_exactly(miss_post.id, both_post.id)
|
||||
end
|
||||
|
||||
it 'keeps a missing qualified external name empty' do
|
||||
get '/posts', params: { tags: 'nico:missing_search_external' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(0)
|
||||
expect(json.fetch('posts')).to be_empty
|
||||
end
|
||||
|
||||
it 'applies the same mixed name search to the existing random endpoint' do
|
||||
get '/posts/random', params: { tags: "#{ tag.name } nico:search_external", match: 'all' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('id')).to eq(both_post.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when tags contain not:' do
|
||||
let!(:foo_tag_name) { TagName.create!(name: 'not_spec_foo') }
|
||||
let!(:foo_tag) { Tag.create!(tag_name: foo_tag_name, category: :general) }
|
||||
@@ -612,6 +719,30 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(json.fetch('count')).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns internal and external tags with colliding ids in the legacy tags array' do
|
||||
external_tag = create(:external_tag, id: tag.id, name: 'post_index_external')
|
||||
PostExternalTag.create!(post: hit_post, external_tag:)
|
||||
|
||||
get '/posts'
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
post_json =
|
||||
json
|
||||
.fetch('posts')
|
||||
.find { _1.fetch('id') == hit_post.id }
|
||||
|
||||
external_json =
|
||||
post_json
|
||||
.fetch('tags')
|
||||
.find { _1['name'] == 'nico:post_index_external' }
|
||||
|
||||
expect(post_json.fetch('tags')).to include(
|
||||
a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general'))
|
||||
expect(external_json).not_to be_nil
|
||||
expect_external_tag_json(external_json, external_tag)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /posts/:id' do
|
||||
@@ -769,6 +900,25 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(query_count).to be <= 45
|
||||
end
|
||||
|
||||
it 'returns external tags as root nodes in the legacy tag tree' do
|
||||
external_tag = create(:external_tag, id: tag.id, name: 'post_detail_external')
|
||||
PostExternalTag.create!(post: post_record, external_tag:)
|
||||
|
||||
request
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
expect(json.fetch('tags')).to include(
|
||||
a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general'))
|
||||
external_json =
|
||||
json
|
||||
.fetch('tags')
|
||||
.find { _1['name'] == 'nico:post_detail_external' }
|
||||
|
||||
expect(external_json).not_to be_nil
|
||||
expect_external_tag_json(external_json, external_tag)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when post does not exist' do
|
||||
@@ -1250,9 +1400,9 @@ RSpec.describe 'Posts API', type: :request do
|
||||
)
|
||||
end
|
||||
|
||||
context 'when nico tag already exists in tags' do
|
||||
context 'when the external nico tag already exists' do
|
||||
before do
|
||||
Tag.find_or_create_by_tag_name!('nico:nico_tag', category: :nico)
|
||||
create(:external_tag, name: 'nico_tag')
|
||||
end
|
||||
|
||||
it 'returns 422 with tag field errors' do
|
||||
@@ -1530,9 +1680,9 @@ RSpec.describe 'Posts API', type: :request do
|
||||
|
||||
versions = post_record.post_versions.order(:version_no)
|
||||
expect(versions.first.tags_json).to include(
|
||||
a_hash_including('id' => tag.id,
|
||||
a_hash_including('tag_id' => tag.id,
|
||||
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => 2000 }]))
|
||||
expect(versions.last.tags_json.map { |item| item.fetch('id') })
|
||||
expect(versions.last.tags_json.map { |item| item.fetch('tag_id') })
|
||||
.not_to include(tag.id)
|
||||
end
|
||||
|
||||
@@ -1551,7 +1701,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(PostTag.find_by!(post: post_record, tag:).created_user).to eq(member)
|
||||
expect(tag.reload.post_count).to eq(1)
|
||||
snapshots = post_record.post_versions.order(:version_no).map do |version|
|
||||
version.tags_json.map { |item| item.fetch('id') }
|
||||
version.tags_json.map { |item| item.fetch('tag_id') }
|
||||
end
|
||||
expect(snapshots.map { |ids| ids.include?(tag.id) }).to eq([true, false, true])
|
||||
end
|
||||
@@ -1576,9 +1726,9 @@ RSpec.describe 'Posts API', type: :request do
|
||||
)
|
||||
end
|
||||
|
||||
context 'when nico tag already exists in tags' do
|
||||
context 'when the external nico tag already exists' do
|
||||
before do
|
||||
Tag.find_or_create_by_tag_name!('nico:nico_tag', category: :nico)
|
||||
create(:external_tag, name: 'nico_tag')
|
||||
end
|
||||
|
||||
it 'returns 422 with tag field errors' do
|
||||
@@ -1895,7 +2045,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
base_version = create_post_version_for!(post_record.reload)
|
||||
|
||||
nico_tag = create_nico_tag!('nico:optimistic_lock_nico')
|
||||
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
|
||||
PostExternalTag.create!(post: post_record, external_tag: nico_tag)
|
||||
|
||||
PostVersionRecorder.record!(
|
||||
post: post_record.reload,
|
||||
@@ -1915,14 +2065,14 @@ RSpec.describe 'Posts API', type: :request do
|
||||
|
||||
expect(names).to include('spec_tag')
|
||||
expect(names).to include(Tag.no_deerjikist.name)
|
||||
expect(names).to include(nico_tag.name)
|
||||
expect(post_record.external_tags).to contain_exactly(nico_tag)
|
||||
end
|
||||
|
||||
it 'keeps nico tags even when they are not included in PUT tags' do
|
||||
sign_in_as(member)
|
||||
|
||||
nico_tag = create_nico_tag!('nico:readonly_update_nico')
|
||||
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
|
||||
PostExternalTag.create!(post: post_record, external_tag: nico_tag)
|
||||
|
||||
base_version = create_post_version_for!(post_record.reload)
|
||||
|
||||
@@ -1937,7 +2087,15 @@ RSpec.describe 'Posts API', type: :request do
|
||||
|
||||
expect(names).to include('spec_tag')
|
||||
expect(names).to include(Tag.no_deerjikist.name)
|
||||
expect(names).to include(nico_tag.name)
|
||||
expect(post_record.external_tags).to contain_exactly(nico_tag)
|
||||
|
||||
external_json =
|
||||
json
|
||||
.fetch('tags')
|
||||
.find { _1['name'] == "nico:#{ nico_tag.name }" }
|
||||
|
||||
expect(external_json).not_to be_nil
|
||||
expect_external_tag_json(external_json, nico_tag)
|
||||
end
|
||||
|
||||
it 'allows non-nico tags linked from nico tags to be removed by normal post update' do
|
||||
@@ -1947,7 +2105,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general)
|
||||
|
||||
NicoTagRelation.create!(nico_tag:, tag: linked_tag)
|
||||
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
|
||||
PostExternalTag.create!(post: post_record, external_tag: nico_tag)
|
||||
PostTag.create!(post: post_record, tag: linked_tag, created_user: member)
|
||||
|
||||
base_version = create_post_version_for!(post_record.reload)
|
||||
@@ -1961,7 +2119,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
|
||||
names = post_record.reload.tags.map(&:name)
|
||||
|
||||
expect(names).to include(nico_tag.name)
|
||||
expect(post_record.external_tags).to contain_exactly(nico_tag)
|
||||
expect(names).to include('spec_tag')
|
||||
expect(names).to include(Tag.no_deerjikist.name)
|
||||
expect(names).not_to include(linked_tag.name)
|
||||
@@ -2180,6 +2338,88 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(first.fetch('created_at')).to eq(t_v1.iso8601)
|
||||
end
|
||||
|
||||
context 'with external tag history' do
|
||||
let(:external_id) { tag.id }
|
||||
let(:external) { create(:external_tag, id: external_id) }
|
||||
let(:external_post) { create(:post) }
|
||||
|
||||
before do
|
||||
PostExternalTag.create!(post: external_post, external_tag: external)
|
||||
PostVersionRecorder.record!(
|
||||
post: external_post, event_type: :create, created_by_user: member)
|
||||
|
||||
unrelated_post = create(:post)
|
||||
PostExternalTag.create!(post: unrelated_post, external_tag: create(:external_tag))
|
||||
PostVersionRecorder.record!(
|
||||
post: unrelated_post, event_type: :create, created_by_user: member)
|
||||
end
|
||||
|
||||
it 'prefers Tag over ExternalTag for the legacy tag parameter' do
|
||||
get '/posts/versions', params: { tag: tag.id }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(3)
|
||||
expect(json.fetch('versions').map { [_1.fetch('post_id'), _1.fetch('version_no')] })
|
||||
.to contain_exactly(
|
||||
[post_record.id, 1], [post_record.id, 2], [other_post_version.post_id, 1])
|
||||
end
|
||||
|
||||
it 'explicitly filters ExternalTag even when its id collides with Tag' do
|
||||
get '/posts/versions', params: { external_tag: external.id }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(1)
|
||||
expect(json.fetch('versions')).to contain_exactly(
|
||||
a_hash_including('post_id' => external_post.id, 'version_no' => 1))
|
||||
end
|
||||
|
||||
# Temporary compatibility shim until the frontend sends external_tag explicitly.
|
||||
context 'with legacy tag fallback and no internal Tag with the external id' do
|
||||
let(:external_id) { Tag.maximum(:id).to_i + 10_000 }
|
||||
|
||||
it 'falls back to ExternalTag for the legacy tag parameter' do
|
||||
expect(Tag.exists?(external.id)).to be(false)
|
||||
|
||||
get '/posts/versions', params: { tag: external.id }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(1)
|
||||
expect(json.fetch('versions')).to contain_exactly(
|
||||
a_hash_including('post_id' => external_post.id, 'version_no' => 1))
|
||||
end
|
||||
|
||||
[:tag, :external_tag].each do |parameter|
|
||||
it "includes external removal history through the legacy API's #{ parameter }" do
|
||||
external_post.post_external_tags.destroy_all
|
||||
PostVersionRecorder.record!(
|
||||
post: external_post.reload, event_type: :update, created_by_user: member)
|
||||
|
||||
get '/posts/versions', params: { parameter => external.id }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(2)
|
||||
expect(json.fetch('versions')).to contain_exactly(
|
||||
a_hash_including('post_id' => external_post.id, 'version_no' => 1),
|
||||
a_hash_including('post_id' => external_post.id, 'version_no' => 2))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'can render history containing external identifiers' do
|
||||
PostExternalTag.create!(post: post_record, external_tag: create(:external_tag))
|
||||
post_record.update_columns(version_no: 2)
|
||||
PostVersionRecorder.record!(post: post_record,
|
||||
event_type: :update, created_by_user: member)
|
||||
|
||||
get '/posts/versions', params: { post: post_record.id }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(3)
|
||||
expect(json.fetch('versions').first.fetch('tags')).to include(
|
||||
'name' => tag2.name, 'type' => 'context')
|
||||
end
|
||||
|
||||
it 'filters versions by tag when the current snapshot includes the tag' do
|
||||
get '/posts/versions', params: { post: post_record.id, tag: tag2.id }
|
||||
|
||||
|
||||
@@ -80,38 +80,6 @@ RSpec.describe "TagChildren", type: :request do
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when parent is nico' do
|
||||
before { stub_current_user(admin) }
|
||||
|
||||
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng') }
|
||||
let(:parent_id) { parent.id }
|
||||
let(:child_id) { child.id }
|
||||
|
||||
it 'returns 400 and does not create relation' do
|
||||
expect {
|
||||
do_request
|
||||
}.not_to change(TagImplication, :count)
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when child is nico' do
|
||||
before { stub_current_user(admin) }
|
||||
|
||||
let!(:child) { create(:tag, :nico, name: 'nico:child_ng') }
|
||||
let(:parent_id) { parent.id }
|
||||
let(:child_id) { child.id }
|
||||
|
||||
it 'returns 400 and does not create relation' do
|
||||
expect {
|
||||
do_request
|
||||
}.not_to change(TagImplication, :count)
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "DELETE /tag_children" do
|
||||
@@ -186,31 +154,5 @@ RSpec.describe "TagChildren", type: :request do
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when parent is nico' do
|
||||
before { stub_current_user(admin) }
|
||||
|
||||
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng_delete') }
|
||||
let(:parent_id) { parent.id }
|
||||
let(:child_id) { child.id }
|
||||
|
||||
it 'returns 400' do
|
||||
do_request
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when child is nico' do
|
||||
before { stub_current_user(admin) }
|
||||
|
||||
let!(:child) { create(:tag, :nico, name: 'nico:child_ng_delete') }
|
||||
let(:parent_id) { parent.id }
|
||||
let(:child_id) { child.id }
|
||||
|
||||
it 'returns 400' do
|
||||
do_request
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,6 +30,30 @@ RSpec.describe 'Tags API', type: :request do
|
||||
end
|
||||
|
||||
describe 'GET /tags' do
|
||||
it 'includes legacy external JSON alongside an internal tag with the same id' do
|
||||
external = create(:external_tag, id: tag.id, name: 'spec_external', post_count: 3)
|
||||
|
||||
get '/tags', params: { name: 'spec_' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(2)
|
||||
expect(response_tags).to contain_exactly(
|
||||
a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general'),
|
||||
a_hash_including(
|
||||
'id' => external.id,
|
||||
'name' => 'nico:spec_external',
|
||||
'category' => 'nico',
|
||||
'post_count' => 3,
|
||||
'created_at' => external.created_at.as_json,
|
||||
'updated_at' => external.created_at.as_json,
|
||||
'deprecated_at' => nil,
|
||||
'aliases' => [],
|
||||
'parents' => [],
|
||||
'has_wiki' => false,
|
||||
'material_id' => nil,
|
||||
'has_deerjikists' => false))
|
||||
end
|
||||
|
||||
it 'returns tags with count and metadata' do
|
||||
get '/tags'
|
||||
|
||||
@@ -164,20 +188,79 @@ RSpec.describe 'Tags API', type: :request do
|
||||
Tag.create!(tag_name: TagName.create!(name: 'cat_general'), category: :general)
|
||||
Tag.create!(tag_name: TagName.create!(name: 'cat_material'), category: :material)
|
||||
Tag.create!(tag_name: TagName.create!(name: 'cat_meta'), category: :meta)
|
||||
Tag.create!(tag_name: TagName.create!(name: 'nico:cat_nico'), category: :nico)
|
||||
create(:external_tag, name: 'cat_nico')
|
||||
|
||||
get '/tags', params: { name: 'cat_', order: 'category:asc', limit: 20 }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response_names).to eq(%w[
|
||||
cat_deerjikist
|
||||
cat_meme
|
||||
cat_character
|
||||
cat_general
|
||||
cat_material
|
||||
cat_meta
|
||||
nico:cat_nico
|
||||
])
|
||||
expect(response_names).to eq([
|
||||
'cat_deerjikist', 'cat_meme', 'cat_character',
|
||||
'cat_general', 'cat_material', 'cat_meta', 'nico:cat_nico'])
|
||||
expect(json.fetch('count')).to eq(7)
|
||||
end
|
||||
|
||||
context 'with mixed legacy pagination' do
|
||||
let!(:first_tag) do
|
||||
create(:tag,
|
||||
tag_name: create(:tag_name, name: 'a_mixed_page'),
|
||||
category: :meme)
|
||||
end
|
||||
|
||||
let!(:middle_tag) do
|
||||
create(:tag,
|
||||
tag_name: create(:tag_name, name: 'm_mixed_page'),
|
||||
category: :meta)
|
||||
end
|
||||
|
||||
let!(:last_tag) do
|
||||
create(:tag,
|
||||
tag_name: create(:tag_name, name: 'z_mixed_page'),
|
||||
category: :general)
|
||||
end
|
||||
|
||||
let!(:first_external) do
|
||||
create(:external_tag, id: first_tag.id, name: 'a_mixed_page')
|
||||
end
|
||||
let!(:last_external) do
|
||||
create(:external_tag, id: last_tag.id, name: 'z_mixed_page')
|
||||
end
|
||||
|
||||
let(:name_order) do
|
||||
[
|
||||
[first_tag.id, 'a_mixed_page'], [middle_tag.id, 'm_mixed_page'],
|
||||
[first_external.id, 'nico:a_mixed_page'], [last_external.id, 'nico:z_mixed_page'],
|
||||
[last_tag.id, 'z_mixed_page']]
|
||||
end
|
||||
let(:category_order) do
|
||||
[
|
||||
[first_tag.id, 'a_mixed_page'], [last_tag.id, 'z_mixed_page'],
|
||||
[middle_tag.id, 'm_mixed_page'], [first_external.id, 'nico:a_mixed_page'],
|
||||
[last_external.id, 'nico:z_mixed_page']]
|
||||
end
|
||||
|
||||
['name', 'category'].each do |order|
|
||||
['asc', 'desc'].each do |direction|
|
||||
it "orders the combined records by #{ order }:#{ direction } before paging" do
|
||||
ascending = order == 'name' ? name_order : category_order
|
||||
expected = direction == 'asc' ? ascending : ascending.reverse
|
||||
|
||||
[2, 3].each do |limit|
|
||||
pages = expected.each_slice(limit).to_a + [[]]
|
||||
pages.each.with_index(1) do |expected_page, page|
|
||||
get '/tags', params: {
|
||||
name: 'mixed_page', order: "#{ order }:#{ direction }", page:, limit: }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(5)
|
||||
expect(response_tags.size).to eq(expected_page.size)
|
||||
expect(response_tags.size).to be <= limit
|
||||
expect(response_tags.map { [_1.fetch('id'), _1.fetch('name')] })
|
||||
.to eq(expected_page)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'paginates and keeps total count' do
|
||||
@@ -296,9 +379,125 @@ RSpec.describe 'Tags API', type: :request do
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
it 'falls back to ExternalTag when no internal Tag has the id' do
|
||||
external = create(
|
||||
:external_tag,
|
||||
id: Tag.maximum(:id).to_i + 10_000,
|
||||
name: 'legacy_id_lookup')
|
||||
|
||||
get "/tags/#{ external.id }"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'id' => external.id,
|
||||
'name' => 'nico:legacy_id_lookup',
|
||||
'category' => 'nico')
|
||||
end
|
||||
|
||||
it 'prefers Tag when Tag and ExternalTag have the same id' do
|
||||
internal = create(
|
||||
:tag,
|
||||
tag_name: create(:tag_name, name: 'internal_collision'))
|
||||
create(
|
||||
:external_tag,
|
||||
id: internal.id,
|
||||
name: 'external_collision')
|
||||
|
||||
get "/tags/#{ internal.id }"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'id' => internal.id,
|
||||
'name' => 'internal_collision',
|
||||
'category' => internal.category)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /tags/autocomplete' do
|
||||
it 'combines internal and external matches without conflating equal ids' do
|
||||
internal = Tag.create!(category: :general, name: 'mixed_internal', post_count: 2)
|
||||
external = create(:external_tag, id: internal.id,
|
||||
name: 'mixed_external', post_count: 3)
|
||||
|
||||
get '/tags/autocomplete', params: { q: 'not:mixed' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.map { |row| row.fetch('name') })
|
||||
.to eq(['nico:mixed_external', 'mixed_internal'])
|
||||
expect(json.first).to include(
|
||||
'id' => external.id, 'category' => 'nico', 'post_count' => 3,
|
||||
'created_at' => external.created_at.as_json,
|
||||
'updated_at' => external.created_at.as_json,
|
||||
'deprecated_at' => nil, 'matched_alias' => nil,
|
||||
'aliases' => [], 'parents' => [], 'has_wiki' => false,
|
||||
'material_id' => nil, 'has_deerjikists' => false)
|
||||
end
|
||||
|
||||
it 'matches an external tag by its platform prefix' do
|
||||
create(:external_tag, name: 'prefix_match', post_count: 1)
|
||||
|
||||
get '/tags/autocomplete', params: { q: 'nico:prefix' }
|
||||
|
||||
expect(json.map { |row| row.fetch('name') }).to eq(['nico:prefix_match'])
|
||||
end
|
||||
|
||||
it 'excludes unused external tags unless present is false' do
|
||||
create(:external_tag, name: 'unused_external')
|
||||
|
||||
get '/tags/autocomplete', params: { q: 'unused' }
|
||||
expect(json).to be_empty
|
||||
|
||||
get '/tags/autocomplete', params: { q: 'unused', present: '0' }
|
||||
expect(json.map { |row| row.fetch('name') }).to eq(['nico:unused_external'])
|
||||
end
|
||||
|
||||
it 'limits the combined results to 20 and sorts ties by displayed name' do
|
||||
11.times do |i|
|
||||
name = "combined_#{ i.to_s.rjust(2, '0') }"
|
||||
Tag.create!(category: :general, name:, post_count: 1)
|
||||
create(:external_tag, name:, post_count: 1)
|
||||
end
|
||||
|
||||
get '/tags/autocomplete', params: { q: 'combined' }
|
||||
|
||||
expected = 11.times.map { |i| "combined_#{ i.to_s.rjust(2, '0') }" }
|
||||
expected += expected.map { |name| "nico:#{ name }" }
|
||||
expect(json.map { |row| row.fetch('name') }).to eq(expected.first(20))
|
||||
end
|
||||
|
||||
it 'excludes external tags but preserves internal alias matches when nico is false' do
|
||||
internal = Tag.create!(category: :general, name: 'switch_internal', post_count: 1)
|
||||
alias_target = Tag.create!(category: :general, name: 'alias_target', post_count: 1)
|
||||
TagName.create!(name: 'switch_alias', canonical: alias_target.tag_name)
|
||||
create(:external_tag, name: 'switch_external', post_count: 1)
|
||||
|
||||
get '/tags/autocomplete', params: { q: 'switch', nico: '0' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to contain_exactly(
|
||||
a_hash_including('id' => internal.id, 'name' => internal.name),
|
||||
a_hash_including('id' => alias_target.id, 'name' => alias_target.name,
|
||||
'matched_alias' => 'switch_alias'))
|
||||
end
|
||||
|
||||
['%', '_'].each do |wildcard|
|
||||
it "treats #{ wildcard } literally for canonical, alias, and external names" do
|
||||
literal = "literal#{ wildcard }match"
|
||||
Tag.create!(category: :general, name: literal, post_count: 1)
|
||||
alias_target = Tag.create!(category: :general, name: 'literal_alias_target', post_count: 1)
|
||||
TagName.create!(name: "#{ literal }_alias", canonical: alias_target.tag_name)
|
||||
create(:external_tag, name: literal, post_count: 1)
|
||||
Tag.create!(category: :general, name: 'literalXmatch', post_count: 2)
|
||||
create(:external_tag, name: 'literalXmatch', post_count: 2)
|
||||
|
||||
get '/tags/autocomplete', params: { q: "literal#{ wildcard }" }
|
||||
|
||||
expect(json.map { |row| row.fetch('name') })
|
||||
.to contain_exactly(literal, alias_target.name, "nico:#{ literal }")
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns matching tags by q' do
|
||||
get '/tags/autocomplete', params: { q: 'spec' }
|
||||
|
||||
@@ -340,6 +539,27 @@ RSpec.describe 'Tags API', type: :request do
|
||||
end
|
||||
|
||||
describe 'GET /tags/name/:name' do
|
||||
it 'preserves qualified external name lookup used by wiki pages' do
|
||||
external = create(:external_tag, name: 'detail_external', post_count: 3)
|
||||
|
||||
get "/tags/name/#{ CGI.escape('nico:detail_external') }"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'id' => external.id,
|
||||
'name' => 'nico:detail_external',
|
||||
'category' => 'nico',
|
||||
'post_count' => 3,
|
||||
'created_at' => external.created_at.as_json,
|
||||
'updated_at' => external.created_at.as_json,
|
||||
'deprecated_at' => nil,
|
||||
'aliases' => [],
|
||||
'parents' => [],
|
||||
'has_wiki' => false,
|
||||
'material_id' => nil,
|
||||
'has_deerjikists' => false)
|
||||
end
|
||||
|
||||
it 'returns tag by name' do
|
||||
get "/tags/name/#{ CGI.escape('spec_tag') }"
|
||||
|
||||
@@ -487,18 +707,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
expect(json.fetch('deprecated_at')).to be_present
|
||||
end
|
||||
|
||||
it 'rejects deprecating a nico tag' do
|
||||
nico_tag = Tag.create!(name: 'nico:deprecated_update', category: :nico)
|
||||
|
||||
patch "/tags/#{ nico_tag.id }", params: { deprecated: '1' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(nico_tag.reload.deprecated_at).to be_nil
|
||||
expect(json.fetch('errors')).to include(
|
||||
'deprecated' => ['ニコタグは廃止できません.']
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns 422 when changing normal tag category to nico' do
|
||||
expect {
|
||||
patch "/tags/#{tag.id}", params: { category: 'nico' }
|
||||
@@ -508,32 +716,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
expect(tag.reload.category).to eq('general')
|
||||
end
|
||||
|
||||
it 'returns 422 when updating nico tag name' do
|
||||
nico_tag_name = TagName.create!(name: 'nico:tags_spec_source')
|
||||
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
|
||||
|
||||
expect {
|
||||
patch "/tags/#{ nico_tag.id }", params: { name: 'nico:tags_spec_renamed' }
|
||||
}.not_to change(NicoTagVersion, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
|
||||
expect(nico_tag.reload.name).to eq('nico:tags_spec_source')
|
||||
expect(nico_tag.category).to eq('nico')
|
||||
end
|
||||
|
||||
it 'returns 422 when changing nico tag category to normal category' do
|
||||
nico_tag_name = TagName.create!(name: 'nico:category_change_ng')
|
||||
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
|
||||
|
||||
expect {
|
||||
patch "/tags/#{nico_tag.id}", params: { category: 'general' }
|
||||
}.not_to change(NicoTagVersion, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(nico_tag.reload.category).to eq('nico')
|
||||
end
|
||||
|
||||
it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do
|
||||
wiki_page =
|
||||
Wiki::Commit.create_content!(
|
||||
@@ -1169,28 +1351,6 @@ RSpec.describe 'Tags API', type: :request do
|
||||
expect(tag.category).to eq('general')
|
||||
end
|
||||
|
||||
it 'nico tag は更新できない' do
|
||||
nico_tag = Tag.create!(
|
||||
tag_name: TagName.create!(name: 'nico:put_update_all_ng'),
|
||||
category: :nico
|
||||
)
|
||||
|
||||
expect {
|
||||
put "/tags/#{ nico_tag.id }", params: {
|
||||
name: 'nico:put_update_all_renamed',
|
||||
category: 'nico',
|
||||
aliases: '',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
}.not_to change(NicoTagVersion, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
|
||||
expect(nico_tag.reload.name).to eq('nico:put_update_all_ng')
|
||||
expect(nico_tag.category).to eq('nico')
|
||||
end
|
||||
|
||||
it 'system tag の name は変更できない' do
|
||||
system_tag = Tag.tagme
|
||||
old_name = system_tag.name
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする