From 2490ed91c447a0097ff5c5814180eb9f7b1f4e2b Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 23 Sep 2026 09:41:41 +0900 Subject: [PATCH] #419 --- backend/spec/models/nico_tag_relation_spec.rb | 16 +--- backend/spec/requests/nico_tags_spec.rb | 31 +++---- backend/spec/requests/posts_spec.rb | 92 ++++++++++++++++++- backend/spec/requests/tags_spec.rb | 88 ++++++++++++++---- .../nico_tag_version_recorder_spec.rb | 16 ++++ 5 files changed, 194 insertions(+), 49 deletions(-) diff --git a/backend/spec/models/nico_tag_relation_spec.rb b/backend/spec/models/nico_tag_relation_spec.rb index 91012f8..89973ed 100644 --- a/backend/spec/models/nico_tag_relation_spec.rb +++ b/backend/spec/models/nico_tag_relation_spec.rb @@ -1,20 +1,12 @@ require 'rails_helper' RSpec.describe NicoTagRelation, type: :model do - it 'accepts an ExternalTag regardless of its platform' do - id = ExternalTag.maximum(:id).to_i + 10_000 - - ExternalTag.insert_all!([ - { id:, - platform: 'spec_external', - name: 'external_name', - post_count: 0, - created_at: Time.current }]) - - external_tag = ExternalTag.find(id) + it 'does not constrain the ExternalTag association to the nico platform' do + external_tag = create(:external_tag) tag = create(:tag) + allow(external_tag).to receive(:platform).and_return('registered_external') + allow(external_tag).to receive(:nico?).and_return(false) - expect(external_tag.platform_before_type_cast).to eq('spec_external') expect { described_class.create!(nico_tag: external_tag, tag:) }.to change(described_class, :count).by(1) diff --git a/backend/spec/requests/nico_tags_spec.rb b/backend/spec/requests/nico_tags_spec.rb index 46faad5..b1572dd 100644 --- a/backend/spec/requests/nico_tags_spec.rb +++ b/backend/spec/requests/nico_tags_spec.rb @@ -27,23 +27,6 @@ RSpec.describe 'NicoTags', type: :request do 'linked_tags' => [])) end - it 'lists ExternalTag records from every platform through the legacy URI' do - external = create(:external_tag, name: 'platform_contract_nico') - other_id = ExternalTag.maximum(:id) + 10_000 - # A second platform is not registered in the enum yet. - ExternalTag.insert_all!([ - { id: other_id, platform: 'spec_external', name: 'platform_contract_other', - post_count: 0, created_at: Time.current }]) - - get '/tags/nico', params: { name: 'platform_contract_' } - - expect(response).to have_http_status(:ok) - expect(json.fetch('count')).to eq(2) - expect(json.fetch('tags')).to contain_exactly( - a_hash_including('id' => external.id, 'category' => 'nico'), - a_hash_including('id' => other_id, 'category' => 'nico')) - end - it 'returns paginated tags and total count' do 3.times { |i| create(:external_tag, name: "pagination_#{ i }") } @@ -82,6 +65,20 @@ 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(:external_tag) older.update!(name: 'ordered_a') diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 5200bfb..8660634 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -253,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) } @@ -2286,7 +2373,8 @@ RSpec.describe 'Posts API', type: :request do a_hash_including('post_id' => external_post.id, 'version_no' => 1)) end - context 'without an internal Tag with the external id' do + # 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 @@ -2301,7 +2389,7 @@ RSpec.describe 'Posts API', type: :request do end [:tag, :external_tag].each do |parameter| - it "includes external tag removal history through #{ parameter }" do + 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) diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index e4f167f..44aeea6 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -54,24 +54,6 @@ RSpec.describe 'Tags API', type: :request do 'has_deerjikists' => false)) end - it 'uses category nico to select ExternalTag records regardless of platform' do - external = create(:external_tag, name: 'platform_contract_nico') - create(:tag, name: 'platform_contract_internal') - other_id = ExternalTag.maximum(:id) + 10_000 - # A second platform is not registered in the enum yet. - ExternalTag.insert_all!([ - { id: other_id, platform: 'spec_external', name: 'platform_contract_other', - post_count: 0, created_at: Time.current }]) - - get '/tags', params: { category: 'nico', name: 'platform_contract_' } - - expect(response).to have_http_status(:ok) - expect(json.fetch('count')).to eq(2) - expect(response_tags).to contain_exactly( - a_hash_including('id' => external.id, 'category' => 'nico'), - a_hash_including('id' => other_id, 'category' => 'nico')) - end - it 'returns tags with count and metadata' do get '/tags' @@ -217,6 +199,55 @@ RSpec.describe 'Tags API', type: :request do expect(json.fetch('count')).to eq(7) end + context 'with mixed legacy pagination' do + let!(:first_tag) { create(:tag, name: 'a_mixed_page', category: :meme) } + let!(:middle_tag) { create(:tag, name: 'm_mixed_page', category: :meta) } + let!(:last_tag) { create(:tag, name: 'z_mixed_page', category: :general) } + 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 %w[pag_a pag_b pag_c].each do |name| Tag.create!(tag_name: TagName.create!(name:), category: :general) @@ -460,6 +491,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') }" diff --git a/backend/spec/services/nico_tag_version_recorder_spec.rb b/backend/spec/services/nico_tag_version_recorder_spec.rb index 48dcf28..e90d1e6 100644 --- a/backend/spec/services/nico_tag_version_recorder_spec.rb +++ b/backend/spec/services/nico_tag_version_recorder_spec.rb @@ -27,6 +27,22 @@ RSpec.describe NicoTagVersionRecorder do expect(external_tag.reload.has_attribute?(:version_no)).to be(false) end + it 'builds the qualified name from the registered platform value' do + external = create(:external_tag, name: 'foo') + locked_scope = instance_double(ActiveRecord::Relation) + allow(ExternalTag).to receive(:unscoped).and_return(locked_scope) + allow(locked_scope).to receive(:lock).and_return(locked_scope) + allow(locked_scope).to receive(:find).with(external.id).and_return(external) + allow(external).to receive(:platform).and_return('registered_external') + + version = described_class.record!( + external_tag: external, event_type: :create, created_by_user: member) + + expect(version).to have_attributes( + external_tag: external, name: 'registered_external:foo', version_no: 1, + event_type: 'create', linked_tags: '', created_by_user: member) + end + it 'returns the latest version without appending an unchanged snapshot' do first = record(:create)