外部タグを分離 (#419) (#420)

**本番 DB のバックアップをかならずすること**

Reviewed-on: #420
Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #420 でマージされました。
このコミットが含まれているのは:
2026-09-25 01:15:06 +09:00
committed by みてるぞ
コミット f336b2f13b
58個のファイルの変更、2439行の追加、630行の削除
+254 -14
ファイルの表示
@@ -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 }