このコミットが含まれているのは:
2026-09-23 09:41:41 +09:00
コミット 2490ed91c4
5個のファイルの変更194行の追加49行の削除
+4 -12
ファイルの表示
@@ -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)
+14 -17
ファイルの表示
@@ -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')
+90 -2
ファイルの表示
@@ -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)
+70 -18
ファイルの表示
@@ -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') }"
+16
ファイルの表示
@@ -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)