From 2e25c6e0ba5b097ba42b877ab3f3d7c6ad0f150d Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 22 Sep 2026 20:33:46 +0900 Subject: [PATCH] #419 --- backend/lib/tasks/sync_nico.rake | 2 +- backend/spec/db/external_tags_spec.rb | 191 ++++++++++++++++++ backend/spec/factories/external_tags.rb | 6 + backend/spec/factories/tags.rb | 7 - backend/spec/models/external_tag_spec.rb | 38 ++++ backend/spec/models/post_external_tag_spec.rb | 25 +++ backend/spec/models/post_spec.rb | 19 ++ backend/spec/models/tag_spec.rb | 92 +++------ backend/spec/models/version_record_spec.rb | 10 +- backend/spec/requests/nico_tags_spec.rb | 73 +++---- backend/spec/requests/posts_spec.rb | 56 +++-- backend/spec/requests/tag_children_spec.rb | 58 ------ backend/spec/requests/tags_spec.rb | 150 +++++++------- .../nico_tag_version_recorder_spec.rb | 60 ++++++ .../spec/services/post_create_plan_spec.rb | 13 ++ backend/spec/tasks/nico_sync_spec.rb | 59 ++++-- 16 files changed, 593 insertions(+), 266 deletions(-) create mode 100644 backend/spec/db/external_tags_spec.rb create mode 100644 backend/spec/factories/external_tags.rb create mode 100644 backend/spec/models/external_tag_spec.rb create mode 100644 backend/spec/models/post_external_tag_spec.rb create mode 100644 backend/spec/services/nico_tag_version_recorder_spec.rb diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake index 11efcde..450921e 100644 --- a/backend/lib/tasks/sync_nico.rake +++ b/backend/lib/tasks/sync_nico.rake @@ -134,7 +134,7 @@ namespace :nico do kept_tag_ids = post.tags.pluck(:id).to_set # 既存の外部タグ 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. のリスト desired_external_tag_ids = [] diff --git a/backend/spec/db/external_tags_spec.rb b/backend/spec/db/external_tags_spec.rb new file mode 100644 index 0000000..4159bdc --- /dev/null +++ b/backend/spec/db/external_tags_spec.rb @@ -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 diff --git a/backend/spec/factories/external_tags.rb b/backend/spec/factories/external_tags.rb new file mode 100644 index 0000000..09a9c56 --- /dev/null +++ b/backend/spec/factories/external_tags.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :external_tag do + platform { :nico } + sequence(:name) { |n| "external_tag_#{ n }" } + end +end diff --git a/backend/spec/factories/tags.rb b/backend/spec/factories/tags.rb index 9da4b9d..55db316 100644 --- a/backend/spec/factories/tags.rb +++ b/backend/spec/factories/tags.rb @@ -11,12 +11,5 @@ FactoryBot.define do after(:build) do |tag, evaluator| tag.name = evaluator.name if evaluator.name.present? end - - trait :nico do - category { :nico } - transient do - name { "nico:#{ SecureRandom.hex(4) }" } - end - end end end diff --git a/backend/spec/models/external_tag_spec.rb b/backend/spec/models/external_tag_spec.rb new file mode 100644 index 0000000..3d0bd86 --- /dev/null +++ b/backend/spec/models/external_tag_spec.rb @@ -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 diff --git a/backend/spec/models/post_external_tag_spec.rb b/backend/spec/models/post_external_tag_spec.rb new file mode 100644 index 0000000..02bd2a7 --- /dev/null +++ b/backend/spec/models/post_external_tag_spec.rb @@ -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 diff --git a/backend/spec/models/post_spec.rb b/backend/spec/models/post_spec.rb index c097fdf..ea5646e 100644 --- a/backend/spec/models/post_spec.rb +++ b/backend/spec/models/post_spec.rb @@ -6,6 +6,25 @@ RSpec.describe Post, type: :model do PostUrlSanitisationRule.unscoped.delete_all 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 it 'normalises the HTTP URL before applying sanitisation rules' do PostUrlSanitisationRule.create!( diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index e532e25..163873e 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -1,6 +1,36 @@ require 'rails_helper' 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 it 'rejects deprecated tags when deny_deprecated is enabled' do tag_name = TagName.create!(name: 'normalise deprecated tag') @@ -159,20 +189,6 @@ RSpec.describe Tag, type: :model do 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 it 'creates a tag and name with the requested category after stripping whitespace' do tag = nil @@ -290,7 +306,7 @@ RSpec.describe Tag, type: :model do it 'deletes source relationships while preserving unrelated relationships' do parent = 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: child, parent_tag: source_tag) kept_implication = TagImplication.create!(tag: target_tag, parent_tag: parent) @@ -412,48 +428,6 @@ RSpec.describe Tag, type: :model do 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) post.snapshot_tag_names.join(' ') end @@ -499,8 +473,8 @@ RSpec.describe Tag, type: :model do expect(latest.event_type).to eq('update') expect(latest.created_by_user).to be_nil 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(affected_versions.first.tags_json.map { |item| item.fetch('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('tag_id') }) .to eq([source_tag.id]) expect(unaffected_post.reload.post_versions.count).to eq(1) diff --git a/backend/spec/models/version_record_spec.rb b/backend/spec/models/version_record_spec.rb index d3acb34..e76f4e7 100644 --- a/backend/spec/models/version_record_spec.rb +++ b/backend/spec/models/version_record_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe VersionRecord, type: :model do 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 version = TagVersion.create!( @@ -42,10 +42,10 @@ RSpec.describe VersionRecord, type: :model do it 'makes NicoTagVersion read only after create' do version = NicoTagVersion.create!( - tag: nico_tag, + external_tag: nico_tag, version_no: 1, event_type: 'create', - name: nico_tag.name, + name: "nico:#{ nico_tag.name }", linked_tags: '', created_at: Time.current, created_by_user: nil @@ -58,10 +58,10 @@ RSpec.describe VersionRecord, type: :model do it 'prevents NicoTagVersion destroy' do version = NicoTagVersion.create!( - tag: nico_tag, + external_tag: nico_tag, version_no: 1, event_type: 'create', - name: nico_tag.name, + name: "nico:#{ nico_tag.name }", linked_tags: '', created_at: Time.current, created_by_user: nil diff --git a/backend/spec/requests/nico_tags_spec.rb b/backend/spec/requests/nico_tags_spec.rb index b9ac6c6..3a70909 100644 --- a/backend/spec/requests/nico_tags_spec.rb +++ b/backend/spec/requests/nico_tags_spec.rb @@ -4,9 +4,9 @@ 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) + 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(json['tags'].size).to eq(1) @@ -14,12 +14,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) @@ -42,26 +42,26 @@ RSpec.describe 'NicoTags', type: :request do 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) @@ -71,7 +71,7 @@ RSpec.describe 'NicoTags', type: :request do 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 +85,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 +104,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 +127,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 diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index fb08d21..71afb9a 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -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 @@ -1250,9 +1250,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 +1530,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 +1551,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 +1576,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 +1895,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 +1915,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 +1937,7 @@ 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 '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) 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 +1961,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 +2180,34 @@ RSpec.describe 'Posts API', type: :request do expect(first.fetch('created_at')).to eq(t_v1.iso8601) 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 get '/posts/versions', params: { post: post_record.id, tag: tag2.id } diff --git a/backend/spec/requests/tag_children_spec.rb b/backend/spec/requests/tag_children_spec.rb index 69bb10a..dd22144 100644 --- a/backend/spec/requests/tag_children_spec.rb +++ b/backend/spec/requests/tag_children_spec.rb @@ -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 diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 0055364..4d48d60 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -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_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']) end it 'paginates and keeps total count' do @@ -299,6 +293,82 @@ RSpec.describe 'Tags API', type: :request do 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, + '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 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 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 +566,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 +1201,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 diff --git a/backend/spec/services/nico_tag_version_recorder_spec.rb b/backend/spec/services/nico_tag_version_recorder_spec.rb new file mode 100644 index 0000000..48dcf28 --- /dev/null +++ b/backend/spec/services/nico_tag_version_recorder_spec.rb @@ -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 diff --git a/backend/spec/services/post_create_plan_spec.rb b/backend/spec/services/post_create_plan_spec.rb index 5558d81..71a19c1 100644 --- a/backend/spec/services/post_create_plan_spec.rb +++ b/backend/spec/services/post_create_plan_spec.rb @@ -50,6 +50,19 @@ RSpec.describe PostCreatePlan do expect(plan[:video_ms]).to eq(60_000) 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 long_name = 'a' * 256 counts = [TagName.count, Tag.count] diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index 542eb2d..dbca0ee 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -29,7 +29,7 @@ RSpec.describe 'nico:sync' do # 追加される linked tag を準備(nico tag に紐付く一般タグ) 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) # 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') 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(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z')) @@ -115,12 +115,12 @@ RSpec.describe 'nico:sync' do ) # 旧nicoタグ(今回の同期結果に含まれない) - old_nico = create_tag!('nico:OLD', category: 'nico') - PostTag.create!(post:, tag: old_nico) + old_nico = create_external_tag!('OLD') + PostExternalTag.create!(post:, external_tag: old_nico) create_post_version_for!(post) # 今回は NEW のみ欲しい - new_nico = create_tag!('nico:NEW', category: 'nico') + new_nico = create_external_tag!('NEW') # bot/tagme 念のため Tag.bot @@ -131,23 +131,21 @@ RSpec.describe 'nico:sync' do 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(new_nico.reload.post_count).to eq(1) 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) - 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) - 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) # NEW は active にいる post.reload - active_names = post.tags.joins(:tag_name).pluck('tag_names.name') - expect(active_names).to include('nico:NEW') - expect(active_names).not_to include('nico:OLD') + expect(post.external_tags).to contain_exactly(new_nico) end def snapshot_tags(post) @@ -213,7 +211,7 @@ RSpec.describe 'nico:sync' do create_post_version_for!(post) 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) Tag.bot @@ -241,7 +239,7 @@ RSpec.describe 'nico:sync' do end it '既存 post に差分が無いときは新しい version を作らない' do - nico = create_tag!('nico:AAA', category: 'nico') + nico = create_external_tag!('AAA') no_deerjikist = create_tag!('ニジラー情報不詳', category: 'meta') post = Post.create!( @@ -252,7 +250,7 @@ RSpec.describe 'nico:sync' do 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) create_post_version_for!(post) @@ -295,7 +293,7 @@ RSpec.describe 'nico:sync' do run_rake_task('nico:sync') }.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 expect(version.version_no).to eq(1) @@ -408,6 +406,35 @@ RSpec.describe 'nico:sync' do run_rake_task('nico:sync') 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 post = create_nico_sync_post!