このコミットが含まれているのは:
2026-09-22 20:33:46 +09:00
コミット 2e25c6e0ba
16個のファイルの変更、593行の追加、266行の削除
+1 -1
ファイルの表示
@@ -134,7 +134,7 @@ namespace :nico do
kept_tag_ids = post.tags.pluck(:id).to_set kept_tag_ids = post.tags.pluck(:id).to_set
# 既存の外部タグ Id. 集合 # 既存の外部タグ Id. 集合
kept_external_tag_ids = post.external_tags.pluck(:id).to_set kept_external_tag_ids = post.external_tags.nico.pluck(:id).to_set
# 記載すべき外部タグ Id. のリスト # 記載すべき外部タグ Id. のリスト
desired_external_tag_ids = [] desired_external_tag_ids = []
+191
ファイルの表示
@@ -0,0 +1,191 @@
require 'rails_helper'
require_relative '../../db/migrate/20260921040000_create_external_tags'
require_relative '../../db/migrate/20260921050000_create_post_external_tags'
require_relative '../../db/migrate/20260921060000_change_foreign_key_on_nico_tag_relations'
require_relative '../../db/migrate/20260921230000_migrate_external_tags'
RSpec.describe 'external tag migrations' do
self.use_transactional_tests = false
before do
record_class = Class.new(ActiveRecord::Base) do
self.abstract_class = true
end
stub_const('ExternalTagMigrationRecord', record_class)
config = ActiveRecord::Base.connection_db_config.configuration_hash
@database = "btrc_hub_test_external_tags_#{ Process.pid }_#{ SecureRandom.hex(4) }"
record_class.establish_connection(config.merge(database: nil))
@connection = record_class.lease_connection
@connection.create_database(@database)
@database_created = true
@connection.execute("USE #{ @connection.quote_table_name(@database) }")
version_class = Class.new(record_class) do
self.table_name = 'post_versions'
end
stub_const('MigrateExternalTags::MigrationPostVersion', version_class)
create_legacy_tables
seed_legacy_records
end
after do
@connection.drop_database(@database) if @database_created
ensure
ExternalTagMigrationRecord.remove_connection
end
def migrate klass
migration = klass.new
allow(migration).to receive(:connection).and_return(@connection)
migration.suppress_messages { migration.migrate(:up) }
end
def create_legacy_tables
@connection.create_table(:tag_names) { |t| t.string :name }
@connection.create_table(:tags) do |t|
t.references :tag_name, foreign_key: true
t.string :category
t.integer :post_count
t.datetime :created_at
end
@connection.create_table(:posts)
@connection.create_table(:post_tags) do |t|
t.references :post, foreign_key: true
t.references :tag, foreign_key: true
t.datetime :created_at
end
@connection.create_table(:nico_tag_versions) do |t|
t.bigint :tag_id
t.integer :version_no
t.string :name
t.datetime :created_at
end
@connection.create_table(:nico_tag_relations) do |t|
t.references :tag, foreign_key: true
t.references :nico_tag, foreign_key: { to_table: :tags }
end
[:post_tag_sections, :materials, :theatre_skip_event_tags].each do |table|
@connection.create_table(table) { |t| t.references :tag, foreign_key: true }
end
@connection.create_table(:tag_implications) do |t|
t.references :tag, foreign_key: true
t.references :parent_tag, foreign_key: { to_table: :tags }
end
@connection.create_table(:tag_similarities) do |t|
t.references :tag, foreign_key: true
t.references :target_tag, foreign_key: { to_table: :tags }
end
@connection.create_table(:post_versions) { |t| t.json :tags_json, null: false }
@connection.add_check_constraint(:post_versions, 'JSON_VALID(tags_json)',
name: 'chk_post_versions_tags_json_schema')
end
def seed_legacy_records
@connection.execute(<<~SQL)
INSERT INTO tag_names (id, name) VALUES (1, 'internal'), (2, 'nico:raw tag[]');
SQL
@connection.execute(<<~SQL)
INSERT INTO tags (id, tag_name_id, category, post_count, created_at) VALUES
(1, 1, 'general', 1, '2026-09-01'), (2, 2, 'nico', 1, '2026-09-02')
SQL
@connection.execute('INSERT INTO posts (id) VALUES (1)')
@connection.execute(<<~SQL)
INSERT INTO post_tags (post_id, tag_id, created_at) VALUES
(1, 1, '2026-09-03'), (1, 2, '2026-09-04')
SQL
@connection.execute(<<~SQL)
INSERT INTO nico_tag_versions (tag_id, version_no, name, created_at) VALUES
(2, 1, 'nico:raw tag[]', '2026-09-02'),
(3, 1, 'nico:deleted', '2026-09-01'),
(3, 2, 'nico:deleted_later', '2026-09-02')
SQL
@connection.execute('INSERT INTO nico_tag_relations (tag_id, nico_tag_id) VALUES (1, 2)')
@connection.execute(<<~SQL)
INSERT INTO tag_similarities (tag_id, target_tag_id) VALUES (1, 1), (1, 2), (2, 1)
SQL
@connection.execute('INSERT INTO theatre_skip_event_tags (tag_id) VALUES (1), (2)')
@legacy_internal = { 'id' => 1, 'version_no' => 2,
'name' => 'internal', 'category' => 'general',
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => nil }] }
snapshots = [@legacy_internal,
{ 'id' => 2, 'category' => 'nico' },
{ 'id' => 3, 'category' => 'nico' }]
@connection.execute(<<~SQL)
INSERT INTO post_versions (tags_json) VALUES
(#{ @connection.quote(snapshots.to_json) }), ('[]')
SQL
end
def prepare_external_tables
migrate(CreateExternalTags)
migrate(CreatePostExternalTags)
migrate(ChangeForeignKeyOnNicoTagRelations)
end
it 'preserves ids, raw names, counts, timestamps, post links and historical-only tags' do
prepare_external_tables
rows = @connection.select_rows(<<~SQL)
SELECT id, platform, name, post_count, DATE_FORMAT(created_at, '%Y-%m-%d')
FROM external_tags ORDER BY id
SQL
expect(rows).to eq([
[2, 'nico', 'raw tag[]', 1, '2026-09-02'],
[3, 'nico', 'deleted', 0, '2026-09-01']])
expect(@connection.select_rows(<<~SQL)).to eq([[1, 2, '2026-09-04']])
SELECT post_id, external_tag_id, DATE_FORMAT(created_at, '%Y-%m-%d')
FROM post_external_tags
SQL
expect(@connection.foreign_key_exists?(:nico_tag_relations, :external_tags,
column: :nico_tag_id)).to be(true)
expect(@connection.foreign_key_exists?(:nico_tag_relations, :tags,
column: :tag_id)).to be(true)
end
it 'converts snapshots and removes only obsolete internal rows and derived links' do
prepare_external_tables
migrate(MigrateExternalTags)
expect(@connection.select_values('SELECT id FROM tags')).to eq([1])
expect(@connection.select_values('SELECT id FROM tag_names')).to eq([1])
expect(@connection.select_values('SELECT tag_id FROM post_tags')).to eq([1])
expect(@connection.select_rows('SELECT tag_id, nico_tag_id FROM nico_tag_relations'))
.to eq([[1, 2]])
expect(@connection.select_rows('SELECT tag_id, target_tag_id FROM tag_similarities'))
.to eq([[1, 1]])
expect(@connection.select_values('SELECT tag_id FROM theatre_skip_event_tags')).to eq([1])
expect(@connection.select_value('SELECT COUNT(*) FROM nico_tag_versions')).to eq(3)
versions = MigrateExternalTags::MigrationPostVersion.order(:id)
expect(versions.first.tags_json).to eq([
@legacy_internal.except('id').merge('tag_id' => 1),
{ 'external_tag_id' => 2 }, { 'external_tag_id' => 3 }])
expect(versions.last.tags_json).to eq([])
invalid_snapshots = [@legacy_internal,
{ 'external_tag_id' => 0 },
{ 'external_tag_id' => 2, 'tag_id' => 1 },
{ 'external_tag_id' => 2, 'name' => 'extra' },
{ 'tag_id' => 1, 'version_no' => 1 }]
invalid_snapshots.each do |snapshot|
expect {
MigrateExternalTags::MigrationPostVersion.create!(tags_json: [snapshot])
}.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
expect { MigrateExternalTags.new.down }
.to raise_error(ActiveRecord::IrreversibleMigration)
end
{ post_tag_sections: '(tag_id) VALUES (2)',
materials: '(tag_id) VALUES (2)',
tag_implications: '(tag_id, parent_tag_id) VALUES (1, 2)' }.each do |table, values|
it "aborts before deleting data when #{ table } references a legacy nico tag" do
prepare_external_tables
@connection.execute("INSERT INTO #{ table } #{ values }")
expect { migrate(MigrateExternalTags) }.to raise_error(RuntimeError, /#{ table }/)
expect(@connection.select_values('SELECT id FROM tags ORDER BY id')).to eq([1, 2])
expect(MigrateExternalTags::MigrationPostVersion.first.tags_json.first)
.to eq(@legacy_internal)
end
end
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :external_tag do
platform { :nico }
sequence(:name) { |n| "external_tag_#{ n }" }
end
end
-7
ファイルの表示
@@ -11,12 +11,5 @@ FactoryBot.define do
after(:build) do |tag, evaluator| after(:build) do |tag, evaluator|
tag.name = evaluator.name if evaluator.name.present? tag.name = evaluator.name if evaluator.name.present?
end end
trait :nico do
category { :nico }
transient do
name { "nico:#{ SecureRandom.hex(4) }" }
end
end
end end
end end
+38
ファイルの表示
@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe ExternalTag, type: :model do
it 'preserves names without internal tag sanitisation or TagName records' do
external = nil
expect {
external = described_class.create!(platform: :nico, name: 'raw tag[]')
}.not_to change(TagName, :count)
expect(external.reload.name).to eq('raw tag[]')
end
it 'deletes post associations without deleting posts or version history' do
external = create(:external_tag)
post = create(:post)
PostExternalTag.create!(post:, external_tag: external)
version = NicoTagVersionRecorder.record!(
external_tag: external, event_type: :create, created_by_user: nil)
external.destroy!
expect(PostExternalTag.where(external_tag_id: external.id)).to be_empty
expect(post.reload.external_tags).to be_empty
expect(version.reload.tag_id).to eq(external.id)
end
it 'deletes external links without deleting linked internal tags' do
external = create(:external_tag)
tag = create(:tag)
NicoTagRelation.create!(nico_tag: external, tag:)
external.destroy!
expect(NicoTagRelation.where(nico_tag_id: external.id)).to be_empty
expect(Tag.exists?(tag.id)).to be(true)
end
end
+25
ファイルの表示
@@ -0,0 +1,25 @@
require 'rails_helper'
RSpec.describe PostExternalTag, type: :model do
it 'exposes both sides and enforces a unique post and external tag pair' do
post = create(:post)
external_tag = create(:external_tag)
described_class.create!(post:, external_tag:)
expect(post.external_tags).to contain_exactly(external_tag)
expect(external_tag.posts).to contain_exactly(post)
expect { described_class.create!(post:, external_tag:) }
.to raise_error(ActiveRecord::RecordNotUnique)
end
it 'removes associations when the post is deleted while retaining the external tag' do
post = create(:post)
external_tag = create(:external_tag)
described_class.create!(post:, external_tag:)
post.destroy!
expect(described_class.where(post_id: post.id)).to be_empty
expect(external_tag.reload.posts).to be_empty
end
end
+19
ファイルの表示
@@ -6,6 +6,25 @@ RSpec.describe Post, type: :model do
PostUrlSanitisationRule.unscoped.delete_all PostUrlSanitisationRule.unscoped.delete_all
end end
describe '#snapshot_tags_json' do
it 'keeps internal snapshots and external identifiers distinct, even with the same id' do
post = create(:post)
tag = create(:tag)
external = create(:external_tag, id: tag.id)
create(:post_tag, post:, tag:)
create(:post_tag_section, post:, tag:, begin_ms: 2000, end_ms: nil)
PostExternalTag.create!(post:, external_tag: external)
expect(post.snapshot_tags_json).to eq([
{ 'tag_id' => tag.id,
'version_no' => tag.version_no,
'name' => tag.name,
'category' => tag.category,
'sections' => [{ 'begin_ms' => 2000, 'end_ms' => nil }] },
{ 'external_tag_id' => external.id }])
end
end
describe 'URL normalisation' do describe 'URL normalisation' do
it 'normalises the HTTP URL before applying sanitisation rules' do it 'normalises the HTTP URL before applying sanitisation rules' do
PostUrlSanitisationRule.create!( PostUrlSanitisationRule.create!(
+33 -59
ファイルの表示
@@ -1,6 +1,36 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Tag, type: :model do RSpec.describe Tag, type: :model do
describe 'external tag separation' do
['nico:reserved', 'NiCo:reserved'].each do |name|
it "rejects the reserved prefix #{ name } for internal tags" do
tag = build(:tag, name:)
expect(tag).to be_invalid
expect(tag.errors[:name]).to be_present
expect {
described_class.normalise_tags!([name])
}.to raise_error(Tag::NicoTagNormalisationError)
end
end
it 'returns external records from the legacy nico_tags entrypoint' do
external = create(:external_tag)
create(:tag)
expect(described_class.nico_tags.where(id: external.id)).to contain_exactly(external)
end
it 'finds external links by the internal tag id even when ids differ' do
tag = create(:tag)
external = create(:external_tag, id: tag.id + 10_000)
# The migration retains existing links without running model validation.
NicoTagRelation.insert_all!([{ tag_id: tag.id, nico_tag_id: external.id }])
expect(tag.linked_nico_tags).to contain_exactly(external)
end
end
describe '.normalise_tags!' do describe '.normalise_tags!' do
it 'rejects deprecated tags when deny_deprecated is enabled' do it 'rejects deprecated tags when deny_deprecated is enabled' do
tag_name = TagName.create!(name: 'normalise deprecated tag') tag_name = TagName.create!(name: 'normalise deprecated tag')
@@ -159,20 +189,6 @@ RSpec.describe Tag, type: :model do
end end
end end
describe 'deprecated validation' do
it 'rejects deprecated nico tags' do
tag = build(
:tag,
name: 'nico:deprecated_validation',
category: :nico,
deprecated_at: Time.current
)
expect(tag).not_to be_valid
expect(tag.errors[:deprecated_at]).to include('ニコタグは廃止できません.')
end
end
describe '.find_or_create_by_tag_name!' do describe '.find_or_create_by_tag_name!' do
it 'creates a tag and name with the requested category after stripping whitespace' do it 'creates a tag and name with the requested category after stripping whitespace' do
tag = nil tag = nil
@@ -290,7 +306,7 @@ RSpec.describe Tag, type: :model do
it 'deletes source relationships while preserving unrelated relationships' do it 'deletes source relationships while preserving unrelated relationships' do
parent = create(:tag) parent = create(:tag)
child = create(:tag) child = create(:tag)
nico_tag = create(:tag, :nico) nico_tag = create(:external_tag)
TagImplication.create!(tag: source_tag, parent_tag: parent) TagImplication.create!(tag: source_tag, parent_tag: parent)
TagImplication.create!(tag: child, parent_tag: source_tag) TagImplication.create!(tag: child, parent_tag: source_tag)
kept_implication = TagImplication.create!(tag: target_tag, parent_tag: parent) kept_implication = TagImplication.create!(tag: target_tag, parent_tag: parent)
@@ -412,48 +428,6 @@ RSpec.describe Tag, type: :model do
end end
end end
context 'when merging a nico source tag' do
let!(:target_tag) do
create(:tag, category: :nico, tag_name: create(:tag_name, name: 'nico:foo'))
end
let!(:source_tag) do
create(:tag, category: :nico, tag_name: create(:tag_name, name: 'nico:bar'))
end
let!(:source_tag_name_id) { source_tag.tag_name_id }
it 'deletes the source tag and name instead of keeping an alias' do
described_class.merge_tags!(target_tag, [source_tag])
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
expect(target_tag.reload.post_count).to eq(0)
end
it 'keeps nico history while deleting source links and allows recreating the name' do
linked_tag = create(:tag)
NicoTagRelation.create!(nico_tag: source_tag, tag: linked_tag)
kept_relation = NicoTagRelation.create!(nico_tag: target_tag, tag: linked_tag)
user = create_member_user!
source_name = source_tag.name
described_class.merge_tags!(target_tag, [source_tag], created_by_user: user)
expect(NicoTagRelation.all).to contain_exactly(kept_relation)
versions = NicoTagVersion.where(tag_id: source_tag.id).order(:version_no)
expect(versions.pluck(:version_no, :event_type))
.to eq([[1, 'create'], [2, 'discard']])
expect(versions.last).to have_attributes(
name: source_name, linked_tags: linked_tag.name, created_by_user: user)
recreated = described_class.find_or_create_by_tag_name!(source_name, category: :nico)
expect(recreated.id).not_to eq(source_tag.id)
expect(recreated.tag_name_id).not_to eq(source_tag_name_id)
expect(recreated.nico_tag_versions).to be_empty
expect(versions.reload.size).to eq(2)
end
end
def snapshot_tags(post) def snapshot_tags(post)
post.snapshot_tag_names.join(' ') post.snapshot_tag_names.join(' ')
end end
@@ -499,8 +473,8 @@ RSpec.describe Tag, type: :model do
expect(latest.event_type).to eq('update') expect(latest.event_type).to eq('update')
expect(latest.created_by_user).to be_nil expect(latest.created_by_user).to be_nil
expect(latest.tags).to eq(snapshot_tags(post_record.reload)) expect(latest.tags).to eq(snapshot_tags(post_record.reload))
expect(latest.tags_json.map { |item| item.fetch('id') }).to eq([target_tag.id]) expect(latest.tags_json.map { |item| item.fetch('tag_id') }).to eq([target_tag.id])
expect(affected_versions.first.tags_json.map { |item| item.fetch('id') }) expect(affected_versions.first.tags_json.map { |item| item.fetch('tag_id') })
.to eq([source_tag.id]) .to eq([source_tag.id])
expect(unaffected_post.reload.post_versions.count).to eq(1) expect(unaffected_post.reload.post_versions.count).to eq(1)
+5 -5
ファイルの表示
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe VersionRecord, type: :model do RSpec.describe VersionRecord, type: :model do
let!(:tag) { create(:tag, name: 'version_record_tag') } let!(:tag) { create(:tag, name: 'version_record_tag') }
let!(:nico_tag) { create(:tag, :nico, name: 'nico:version_record_tag') } let!(:nico_tag) { create(:external_tag, name: 'version_record_tag') }
it 'makes TagVersion read only after create' do it 'makes TagVersion read only after create' do
version = TagVersion.create!( version = TagVersion.create!(
@@ -42,10 +42,10 @@ RSpec.describe VersionRecord, type: :model do
it 'makes NicoTagVersion read only after create' do it 'makes NicoTagVersion read only after create' do
version = NicoTagVersion.create!( version = NicoTagVersion.create!(
tag: nico_tag, external_tag: nico_tag,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
name: nico_tag.name, name: "nico:#{ nico_tag.name }",
linked_tags: '', linked_tags: '',
created_at: Time.current, created_at: Time.current,
created_by_user: nil created_by_user: nil
@@ -58,10 +58,10 @@ RSpec.describe VersionRecord, type: :model do
it 'prevents NicoTagVersion destroy' do it 'prevents NicoTagVersion destroy' do
version = NicoTagVersion.create!( version = NicoTagVersion.create!(
tag: nico_tag, external_tag: nico_tag,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
name: nico_tag.name, name: "nico:#{ nico_tag.name }",
linked_tags: '', linked_tags: '',
created_at: Time.current, created_at: Time.current,
created_by_user: nil created_by_user: nil
+37 -36
ファイルの表示
@@ -4,9 +4,9 @@ require 'rails_helper'
RSpec.describe 'NicoTags', type: :request do RSpec.describe 'NicoTags', type: :request do
describe 'GET /tags/nico' do describe 'GET /tags/nico' do
it 'returns paginated tags and total count' do it 'returns paginated tags and total count' do
create_list(:tag, 3, :nico) 3.times { |i| create(:external_tag, name: "pagination_#{ i }") }
get '/tags/nico', params: { page: 2, limit: 2 } get '/tags/nico', params: { page: 2, limit: 2, name: 'pagination_' }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['tags'].size).to eq(1) expect(json['tags'].size).to eq(1)
@@ -14,12 +14,12 @@ RSpec.describe 'NicoTags', type: :request do
end end
it 'filters by nico tag name, linked tag name, and link status' do it 'filters by nico tag name, linked tag name, and link status' do
linked = create(:tag, :nico) linked = create(:external_tag)
linked.tag_name.update!(name: 'nico:search_linked') linked.update!(name: 'search_linked')
unlinked = create(:tag, :nico) unlinked = create(:external_tag)
unlinked.tag_name.update!(name: 'nico:search_unlinked') unlinked.update!(name: 'search_unlinked')
other = create(:tag, :nico) other = create(:external_tag)
other.tag_name.update!(name: 'nico:other') other.update!(name: 'other')
destination = create(:tag, :general) destination = create(:tag, :general)
destination.tag_name.update!(name: 'destination_search') destination.tag_name.update!(name: 'destination_search')
NicoTagRelation.create!(nico_tag: linked, tag: destination) NicoTagRelation.create!(nico_tag: linked, tag: destination)
@@ -42,26 +42,26 @@ RSpec.describe 'NicoTags', type: :request do
end end
it 'sorts by name and timestamps' do it 'sorts by name and timestamps' do
older = create(:tag, :nico) older = create(:external_tag)
older.tag_name.update!(name: 'nico:a') older.update!(name: 'ordered_a')
older.update_columns(created_at: 2.days.ago) older.update_columns(created_at: 2.days.ago)
newer = create(:tag, :nico) newer = create(:external_tag)
newer.tag_name.update!(name: 'nico:b') newer.update!(name: 'ordered_b')
newer.update_columns(created_at: 1.day.ago) newer.update_columns(created_at: 1.day.ago)
older_post_tag = 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) older_post_tag.update_columns(created_at: 1.hour.ago)
newer_post_tag = 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) 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]) 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]) 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(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'))) 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) .to be_within(1.second).of(older_post_tag.created_at)
@@ -71,7 +71,7 @@ RSpec.describe 'NicoTags', type: :request do
describe 'PATCH /tags/nico/:id' do describe 'PATCH /tags/nico/:id' do
let(:member) { create(:user, :member) } let(:member) { create(:user, :member) }
let(:admin) { create(:user, :admin) } let(:admin) { create(:user, :admin) }
let(:nico_tag) { create(:tag, :nico) } let(:nico_tag) { create(:external_tag) }
it '401 when not logged in' do it '401 when not logged in' do
sign_out sign_out
@@ -85,18 +85,18 @@ RSpec.describe 'NicoTags', type: :request do
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:forbidden)
end 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) sign_in_as(member)
non_nico = create(:tag, :general) non_nico = create(:tag, :general)
expect(ExternalTag.exists?(non_nico.id)).to be(false)
patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' } 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 end
it '200 and updates linked tags while recording tag versions' do it '200 and updates linked tags while recording tag versions' do
sign_in_as(admin) sign_in_as(admin)
nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source') nico_tag = create(:external_tag, name: 'nico_tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
linked_a_name = TagName.create!(name: 'nico_linked_a') linked_a_name = TagName.create!(name: 'nico_linked_a')
linked_a = Tag.create!(tag_name: linked_a_name, category: :general) linked_a = Tag.create!(tag_name: linked_a_name, category: :general)
@@ -104,7 +104,8 @@ RSpec.describe 'NicoTags', type: :request do
linked_b_name = TagName.create!(name: 'nico_linked_b') linked_b_name = TagName.create!(name: 'nico_linked_b')
linked_b = Tag.create!(tag_name: linked_b_name, category: :general) 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 { expect {
patch "/tags/nico/#{nico_tag.id}", params: { patch "/tags/nico/#{nico_tag.id}", params: {
@@ -126,26 +127,26 @@ RSpec.describe 'NicoTags', type: :request do
expect(versions.map(&:event_type)).to eq(['create', 'update']) expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.last.linked_tags.split).to match_array([ expect(versions.last.linked_tags.split).to match_array([
'nico_linked_a', 'nico_linked_a',
'nico_linked_b' 'nico_linked_b'])
])
expect(versions.last.created_by_user_id).to eq(admin.id) expect(versions.last.created_by_user_id).to eq(admin.id)
end 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) sign_in_as(member)
linked = create(:tag)
other_nico = create(:tag, :nico, name: 'nico:linked_ng') NicoTagRelation.insert_all!([{ nico_tag_id: nico_tag.id, tag_id: linked.id }])
TagName.create!(name: 'linked_ng_alias', canonical: other_nico.tag_name) NicoTagVersionRecorder.record!(external_tag: nico_tag,
event_type: :create, created_by_user: member)
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: member)
expect { expect {
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' } patch "/tags/nico/#{ nico_tag.id }", params: { tags: '' }
}.not_to change(NicoTagVersion, :count) }.to change(NicoTagVersion, :count).by(1)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:ok)
expect(json.fetch('errors')).to include( expect(json).to eq([])
'tags' => ['ニコニコ・タグ同士は連携できません.']) 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 end
it 'returns the tags field error when a nico tag is specified directly' do it 'returns the tags field error when a nico tag is specified directly' do
+42 -14
ファイルの表示
@@ -17,7 +17,7 @@ RSpec.describe 'Posts API', type: :request do
end end
def create_nico_tag!(name) 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 end
def dummy_upload def dummy_upload
@@ -1250,9 +1250,9 @@ RSpec.describe 'Posts API', type: :request do
) )
end end
context 'when nico tag already exists in tags' do context 'when the external nico tag already exists' do
before do before do
Tag.find_or_create_by_tag_name!('nico:nico_tag', category: :nico) create(:external_tag, name: 'nico_tag')
end end
it 'returns 422 with tag field errors' do it 'returns 422 with tag field errors' do
@@ -1530,9 +1530,9 @@ RSpec.describe 'Posts API', type: :request do
versions = post_record.post_versions.order(:version_no) versions = post_record.post_versions.order(:version_no)
expect(versions.first.tags_json).to include( 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 }])) '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) .not_to include(tag.id)
end end
@@ -1551,7 +1551,7 @@ RSpec.describe 'Posts API', type: :request do
expect(PostTag.find_by!(post: post_record, tag:).created_user).to eq(member) expect(PostTag.find_by!(post: post_record, tag:).created_user).to eq(member)
expect(tag.reload.post_count).to eq(1) expect(tag.reload.post_count).to eq(1)
snapshots = post_record.post_versions.order(:version_no).map do |version| 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 end
expect(snapshots.map { |ids| ids.include?(tag.id) }).to eq([true, false, true]) expect(snapshots.map { |ids| ids.include?(tag.id) }).to eq([true, false, true])
end end
@@ -1576,9 +1576,9 @@ RSpec.describe 'Posts API', type: :request do
) )
end end
context 'when nico tag already exists in tags' do context 'when the external nico tag already exists' do
before do before do
Tag.find_or_create_by_tag_name!('nico:nico_tag', category: :nico) create(:external_tag, name: 'nico_tag')
end end
it 'returns 422 with tag field errors' do it 'returns 422 with tag field errors' do
@@ -1895,7 +1895,7 @@ RSpec.describe 'Posts API', type: :request do
base_version = create_post_version_for!(post_record.reload) base_version = create_post_version_for!(post_record.reload)
nico_tag = create_nico_tag!('nico:optimistic_lock_nico') 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!( PostVersionRecorder.record!(
post: post_record.reload, post: post_record.reload,
@@ -1915,14 +1915,14 @@ RSpec.describe 'Posts API', type: :request do
expect(names).to include('spec_tag') expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name) 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 end
it 'keeps nico tags even when they are not included in PUT tags' do it 'keeps nico tags even when they are not included in PUT tags' do
sign_in_as(member) sign_in_as(member)
nico_tag = create_nico_tag!('nico:readonly_update_nico') 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) base_version = create_post_version_for!(post_record.reload)
@@ -1937,7 +1937,7 @@ RSpec.describe 'Posts API', type: :request do
expect(names).to include('spec_tag') expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name) 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 end
it 'allows non-nico tags linked from nico tags to be removed by normal post update' do it 'allows non-nico tags linked from nico tags to be removed by normal post update' do
@@ -1947,7 +1947,7 @@ RSpec.describe 'Posts API', type: :request do
linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general) linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general)
NicoTagRelation.create!(nico_tag:, tag: linked_tag) 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) PostTag.create!(post: post_record, tag: linked_tag, created_user: member)
base_version = create_post_version_for!(post_record.reload) base_version = create_post_version_for!(post_record.reload)
@@ -1961,7 +1961,7 @@ RSpec.describe 'Posts API', type: :request do
names = post_record.reload.tags.map(&:name) 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('spec_tag')
expect(names).to include(Tag.no_deerjikist.name) expect(names).to include(Tag.no_deerjikist.name)
expect(names).not_to include(linked_tag.name) expect(names).not_to include(linked_tag.name)
@@ -2180,6 +2180,34 @@ RSpec.describe 'Posts API', type: :request do
expect(first.fetch('created_at')).to eq(t_v1.iso8601) expect(first.fetch('created_at')).to eq(t_v1.iso8601)
end end
it 'does not treat an external id as the internal tag filter' do
external_post = create(:post)
external = create(:external_tag, id: tag.id)
PostExternalTag.create!(post: external_post, external_tag: external)
PostVersionRecorder.record!(post: external_post,
event_type: :create, created_by_user: member)
get '/posts/versions', params: { post: external_post.id, tag: tag.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('versions')).to be_empty
expect(json.fetch('count')).to eq(0)
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 it 'filters versions by tag when the current snapshot includes the tag' do
get '/posts/versions', params: { post: post_record.id, tag: tag2.id } get '/posts/versions', params: { post: post_record.id, tag: tag2.id }
-58
ファイルの表示
@@ -80,38 +80,6 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
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 end
describe "DELETE /tag_children" do describe "DELETE /tag_children" do
@@ -186,31 +154,5 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
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
end end
+80 -70
ファイルの表示
@@ -164,20 +164,14 @@ 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_general'), category: :general)
Tag.create!(tag_name: TagName.create!(name: 'cat_material'), category: :material) 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: '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 } get '/tags', params: { name: 'cat_', order: 'category:asc', limit: 20 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response_names).to eq(%w[ expect(response_names).to eq([
cat_deerjikist 'cat_deerjikist', 'cat_meme', 'cat_character',
cat_meme 'cat_general', 'cat_material', 'cat_meta'])
cat_character
cat_general
cat_material
cat_meta
nico:cat_nico
])
end end
it 'paginates and keeps total count' do it 'paginates and keeps total count' do
@@ -299,6 +293,82 @@ RSpec.describe 'Tags API', type: :request do
end end
describe 'GET /tags/autocomplete' do 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,
'deprecated_at' => nil, 'matched_alias' => nil)
expect(json.first.fetch('updated_at')).to eq(json.first.fetch('created_at'))
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 and alias-only 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(json.map { |row| row.fetch('id') }).to eq([internal.id])
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 it 'returns matching tags by q' do
get '/tags/autocomplete', params: { q: 'spec' } get '/tags/autocomplete', params: { q: 'spec' }
@@ -487,18 +557,6 @@ RSpec.describe 'Tags API', type: :request do
expect(json.fetch('deprecated_at')).to be_present expect(json.fetch('deprecated_at')).to be_present
end 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 it 'returns 422 when changing normal tag category to nico' do
expect { expect {
patch "/tags/#{tag.id}", params: { category: 'nico' } patch "/tags/#{tag.id}", params: { category: 'nico' }
@@ -508,32 +566,6 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.reload.category).to eq('general') expect(tag.reload.category).to eq('general')
end 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 it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do
wiki_page = wiki_page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
@@ -1169,28 +1201,6 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.category).to eq('general') expect(tag.category).to eq('general')
end 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 it 'system tag の name は変更できない' do
system_tag = Tag.tagme system_tag = Tag.tagme
old_name = system_tag.name old_name = system_tag.name
+60
ファイルの表示
@@ -0,0 +1,60 @@
require 'rails_helper'
RSpec.describe NicoTagVersionRecorder do
let(:external_tag) { create(:external_tag, name: 'raw tag[]') }
let(:member) { create(:user, :member) }
def record event_type
described_class.record!(external_tag:, event_type:, created_by_user: member)
end
it 'records the external association and platform-qualified name' do
version = record(:create)
expect(version).to have_attributes(
external_tag:, version_no: 1, event_type: 'create',
name: 'nico:raw tag[]', linked_tags: '', created_by_user: member)
expect(external_tag.reload.nico_tag_versions).to contain_exactly(version)
end
it 'uses the latest history number when the record has no version_no column' do
first = record(:create)
external_tag.update!(name: 'changed')
second = record(:update)
expect(second).to have_attributes(version_no: 2, name: 'nico:changed')
expect(first.reload.name).to eq('nico:raw tag[]')
expect(external_tag.reload.has_attribute?(:version_no)).to be(false)
end
it 'returns the latest version without appending an unchanged snapshot' do
first = record(:create)
expect { expect(record(:update)).to eq(first) }
.not_to change(NicoTagVersion, :count)
end
it 'still requires a create event before any update' do
expect { record(:update) }
.to raise_error(RuntimeError, 'NicoTagVersion first event must be create')
expect(external_tag.nico_tag_versions).to be_empty
end
it 'still rejects a second create event' do
first = record(:create)
expect { record(:create) }
.to raise_error(RuntimeError, 'NicoTagVersion create event already exists')
expect(external_tag.nico_tag_versions).to contain_exactly(first)
end
it 'records sorted linked internal names and later link removal' do
tags = ['z_link', 'a_link'].map { |name| Tag.create!(name:, category: :general) }
tags.each { |tag| NicoTagRelation.create!(nico_tag: external_tag, tag:) }
expect(record(:create).linked_tags).to eq('a_link z_link')
external_tag.linked_tags = [tags.first]
expect(record(:update)).to have_attributes(version_no: 2, linked_tags: 'z_link')
end
end
+13
ファイルの表示
@@ -50,6 +50,19 @@ RSpec.describe PostCreatePlan do
expect(plan[:video_ms]).to eq(60_000) expect(plan[:video_ms]).to eq(60_000)
end end
it 'rejects direct external tag input without creating internal records' do
create(:external_tag, name: 'reserved')
counts = [Tag.count, TagName.count, ExternalTag.count]
expect {
described_class.new(attributes: { tags: 'NiCo:reserved' }).build!
}.to raise_error(ActiveRecord::RecordInvalid) { |error|
expect(error.record.errors[:tags]).to be_present
}
expect([Tag.count, TagName.count, ExternalTag.count]).to eq(counts)
end
it 'validates a new tag name without persisting it' do it 'validates a new tag name without persisting it' do
long_name = 'a' * 256 long_name = 'a' * 256
counts = [TagName.count, Tag.count] counts = [TagName.count, Tag.count]
+43 -16
ファイルの表示
@@ -29,7 +29,7 @@ RSpec.describe 'nico:sync' do
# 追加される linked tag を準備(nico tag に紐付く一般タグ) # 追加される linked tag を準備(nico tag に紐付く一般タグ)
linked = create_tag!('spec_linked', category: 'general') linked = create_tag!('spec_linked', category: 'general')
nico = create_tag!('nico:AAA', category: 'nico') nico = create_external_tag!('AAA')
link_nico_to_tag!(nico, linked) link_nico_to_tag!(nico, linked)
# bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提) # bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提)
@@ -53,7 +53,7 @@ RSpec.describe 'nico:sync' do
active_tag_names = post.tags.joins(:tag_name).pluck('tag_names.name') active_tag_names = post.tags.joins(:tag_name).pluck('tag_names.name')
expect(active_tag_names).to include('spec_kept') expect(active_tag_names).to include('spec_kept')
expect(active_tag_names).to include('nico:AAA') expect(post.external_tags).to contain_exactly(nico)
expect(active_tag_names).to include('spec_linked') expect(active_tag_names).to include('spec_linked')
expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z')) expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z'))
@@ -115,12 +115,12 @@ RSpec.describe 'nico:sync' do
) )
# 旧nicoタグ(今回の同期結果に含まれない) # 旧nicoタグ(今回の同期結果に含まれない)
old_nico = create_tag!('nico:OLD', category: 'nico') old_nico = create_external_tag!('OLD')
PostTag.create!(post:, tag: old_nico) PostExternalTag.create!(post:, external_tag: old_nico)
create_post_version_for!(post) create_post_version_for!(post)
# 今回は NEW のみ欲しい # 今回は NEW のみ欲しい
new_nico = create_tag!('nico:NEW', category: 'nico') new_nico = create_external_tag!('NEW')
# bot/tagme 念のため # bot/tagme 念のため
Tag.bot Tag.bot
@@ -131,23 +131,21 @@ RSpec.describe 'nico:sync' do
run_rake_task('nico:sync') run_rake_task('nico:sync')
expect(PostTag.exists?(post:, tag: old_nico)).to be(false) expect(PostExternalTag.exists?(post:, external_tag: old_nico)).to be(false)
expect(old_nico.reload.post_count).to eq(0) expect(old_nico.reload.post_count).to eq(0)
expect(new_nico.reload.post_count).to eq(1) expect(new_nico.reload.post_count).to eq(1)
versions = post.post_versions.order(:version_no) versions = post.post_versions.order(:version_no)
expect(versions.first.tags_json.map { |item| item.fetch('id') }) expect(versions.first.tags_json.filter_map { |item| item['external_tag_id'] })
.to include(old_nico.id) .to include(old_nico.id)
expect(versions.last.tags_json.map { |item| item.fetch('id') }) expect(versions.last.tags_json.filter_map { |item| item['external_tag_id'] })
.to include(new_nico.id) .to include(new_nico.id)
expect(versions.last.tags_json.map { |item| item.fetch('id') }) expect(versions.last.tags_json.filter_map { |item| item['external_tag_id'] })
.not_to include(old_nico.id) .not_to include(old_nico.id)
# NEW は active にいる # NEW は active にいる
post.reload post.reload
active_names = post.tags.joins(:tag_name).pluck('tag_names.name') expect(post.external_tags).to contain_exactly(new_nico)
expect(active_names).to include('nico:NEW')
expect(active_names).not_to include('nico:OLD')
end end
def snapshot_tags(post) def snapshot_tags(post)
@@ -213,7 +211,7 @@ RSpec.describe 'nico:sync' do
create_post_version_for!(post) create_post_version_for!(post)
linked = create_tag!('spec_linked', category: 'general') linked = create_tag!('spec_linked', category: 'general')
nico = create_tag!('nico:AAA', category: 'nico') nico = create_external_tag!('AAA')
link_nico_to_tag!(nico, linked) link_nico_to_tag!(nico, linked)
Tag.bot Tag.bot
@@ -241,7 +239,7 @@ RSpec.describe 'nico:sync' do
end end
it '既存 post に差分が無いときは新しい version を作らない' do it '既存 post に差分が無いときは新しい version を作らない' do
nico = create_tag!('nico:AAA', category: 'nico') nico = create_external_tag!('AAA')
no_deerjikist = create_tag!('ニジラー情報不詳', category: 'meta') no_deerjikist = create_tag!('ニジラー情報不詳', category: 'meta')
post = Post.create!( post = Post.create!(
@@ -252,7 +250,7 @@ RSpec.describe 'nico:sync' do
original_created_before: Time.iso8601('2026-01-01T03:35:00Z') original_created_before: Time.iso8601('2026-01-01T03:35:00Z')
) )
PostTag.create!(post: post, tag: nico) PostExternalTag.create!(post:, external_tag: nico)
PostTag.create!(post: post, tag: no_deerjikist) PostTag.create!(post: post, tag: no_deerjikist)
create_post_version_for!(post) create_post_version_for!(post)
@@ -295,7 +293,7 @@ RSpec.describe 'nico:sync' do
run_rake_task('nico:sync') run_rake_task('nico:sync')
}.to change(NicoTagVersion, :count).by(1) }.to change(NicoTagVersion, :count).by(1)
nico_tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'nico:AAA' }) nico_tag = ExternalTag.find_by!(platform: :nico, name: 'AAA')
version = nico_tag.nico_tag_versions.order(:version_no).last version = nico_tag.nico_tag_versions.order(:version_no).last
expect(version.version_no).to eq(1) expect(version.version_no).to eq(1)
@@ -408,6 +406,35 @@ RSpec.describe 'nico:sync' do
run_rake_task('nico:sync') run_rake_task('nico:sync')
end end
it '外部タグだけの変更では bot を付けず,投稿履歴を記録する' do
post = create_nico_sync_post!
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
expect {
run_nico_sync_with_tags!(['raw tag[]', 'raw tag[]'])
}.to change(PostVersion, :count).by(1)
.and change(ExternalTag, :count).by(1)
.and change(PostExternalTag, :count).by(1)
.and change(NicoTagVersion, :count).by(1)
.and change(TagName, :count).by(0)
external = post.external_tags.sole
expect(external.name).to eq('raw tag[]')
expect(post.tags.map(&:name)).not_to include('bot操作')
expect(post.post_versions.order(:version_no).last.tags_json)
.to include('external_tag_id' => external.id)
expect {
run_nico_sync_with_tags!(['raw tag[]'])
}.to change(PostVersion, :count).by(0).and change(NicoTagVersion, :count).by(0)
expect {
run_nico_sync_with_tags!([])
}.to change(PostVersion, :count).by(1)
expect(post.reload.external_tags).to be_empty
expect(post.tags.map(&:name)).not_to include('bot操作')
end
it '外部タグが新規記載されたとき,その時点の連携タグを記載する' do it '外部タグが新規記載されたとき,その時点の連携タグを記載する' do
post = create_nico_sync_post! post = create_nico_sync_post!