From d6670bb65e75f68c3c41a5a21c821e0c8a1671d1 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 29 Jun 2026 01:56:36 +0900 Subject: [PATCH] #383 --- backend/app/controllers/tags_controller.rb | 72 ++++++++- backend/spec/requests/tags_spec.rb | 172 +++++++++++++++++++++ 2 files changed, 238 insertions(+), 6 deletions(-) diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index f345a02..cb07ddd 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -245,10 +245,7 @@ class TagsController < ApplicationController return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank? return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated) - if (name != tag.name && - tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])) - return render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name - end + return unless validate_tag_rename(tag, name) alias_names = params[:aliases].to_s.split.uniq parent_names = params[:parent_tags].to_s.split.uniq @@ -274,7 +271,7 @@ class TagsController < ApplicationController else tag.update!(category:, deprecated_at: deprecated ? Time.current : nil) end - tag.tag_name.update!(name:) + rename_tag_name!(tag, name) if name_changed alias_names << old_name if name_changed alias_names.delete(name) @@ -310,6 +307,8 @@ class TagsController < ApplicationController return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated end + return unless validate_tag_rename(tag, name) + if tag.nico? || (category.present? && category == 'nico') return render_unprocessable_entity 'ニコタグは変更できません.', field: :category end @@ -321,7 +320,7 @@ class TagsController < ApplicationController name_changed = name.present? && name != old_name wiki_page = tag.tag_name.wiki_page if name_changed - tag.tag_name.update!(name:) if name.present? + rename_tag_name!(tag, name) if name_changed tag.update!(category:) if category.present? if deprecated_given && tag.deprecated? != deprecated tag.update!(deprecated_at: deprecated ? Time.current : nil) @@ -535,6 +534,67 @@ class TagsController < ApplicationController created_by_user:) end + def validate_tag_rename tag, name + return true if name.blank? || name == tag.name + + if tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]) + render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name + return false + end + + target_tag_name = TagName.with_discarded.find_by(name:) + return true if target_tag_name.nil? + return true if target_tag_name.canonical_id? + + render_unprocessable_entity 'その名前は既に使はれてゐます.', field: :name + false + end + + def rename_tag_name! tag, name + return if name == tag.name + + current_tag_name = tag.tag_name + target_tag_name = TagName.with_discarded.find_by(name:) + + if target_tag_name.nil? + current_tag_name.update!(name:) + return + end + + promote_tag_alias!( + tag, + current_tag_name:, + promoted_tag_name: target_tag_name) + end + + def promote_tag_alias! tag, current_tag_name:, promoted_tag_name: + old_owner_tag = promoted_tag_name.canonical&.tag + + if old_owner_tag && old_owner_tag != tag + TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user) + end + + promoted_tag_name.undiscard! if promoted_tag_name.discarded? + promoted_tag_name.update!(canonical: nil) + + TagName.with_discarded + .where(canonical_id: current_tag_name.id) + .where.not(id: promoted_tag_name.id) + .find_each do |alias_tag_name| + alias_tag_name.update!(canonical: promoted_tag_name) + end + + current_tag_name.wiki_page&.update!(tag_name: promoted_tag_name) + tag.update!(tag_name: promoted_tag_name) + current_tag_name.association(:wiki_page).reset + current_tag_name.association(:tag).reset + current_tag_name.reload.update!(canonical: promoted_tag_name) + + return unless old_owner_tag && old_owner_tag != tag + + record_tag_version!(old_owner_tag.reload, event_type: :update, created_by_user: current_user) + end + def update_aliases! tag, alias_names alias_names = alias_names.uniq diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 927b4a7..ccb28ab 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -581,6 +581,58 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:ok) expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count) end + + it 'full update で旧名 alias が残った tag を PATCH で旧名へ戻せる' do + put "/tags/#{ tag.id }", params: { + name: 'patch_roundtrip_target', + category: 'general', + aliases: 'unko', + parent_tags: '', + deprecated: '0', + } + + expect(response).to have_http_status(:ok) + expect(TagName.find_by!(name: 'spec_tag').canonical).to eq(tag.reload.tag_name) + + patch "/tags/#{ tag.id }", params: { name: 'spec_tag' } + + expect(response).to have_http_status(:ok) + + tag.reload + + expect(tag.name).to eq('spec_tag') + expect(tag.tag_name.canonical_id).to be_nil + expect(TagName.find_by!(name: 'patch_roundtrip_target').canonical).to eq(tag.tag_name) + end + + it '別 tag の正規名には変更できない' do + wiki_page = + Wiki::Commit.create_content!( + tag_name: tag.tag_name, + body: 'patch collision wiki', + created_by_user: member_user, + message: 'init') + + patch "/tags/#{ tag.id }", params: { name: 'unknown' } + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'name' => ['その名前は既に使はれてゐます.'] + ) + + expect(tag.reload.name).to eq('spec_tag') + expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('unko') + expect(wiki_page.reload.tag_name).to eq(tag.tag_name) + end + + it 'system tag の name は変更できない' do + system_tag = Tag.bot + + patch "/tags/#{ system_tag.id }", params: { name: 'patch_system_tag_renamed' } + + expect(response).to have_http_status(:unprocessable_entity) + expect(system_tag.reload.name).to eq('bot操作') + end end end @@ -1029,6 +1081,42 @@ RSpec.describe 'Tags API', type: :request do expect(versions.second.created_by_user_id).to eq(member_user.id) end + it '同じ tag の旧 alias へ戻しても rename できる' do + put "/tags/#{ tag.id }", params: { + name: 'put_roundtrip_b', + category: 'general', + aliases: 'unko', + parent_tags: '', + deprecated: '0', + } + + expect(response).to have_http_status(:ok) + + put "/tags/#{ tag.id }", params: { + name: 'spec_tag', + category: 'general', + aliases: 'unko', + parent_tags: '', + deprecated: '0', + } + + expect(response).to have_http_status(:ok) + + tag.reload + + expect(tag.name).to eq('spec_tag') + expect(TagName.find_by!(name: 'put_roundtrip_b').canonical).to eq(tag.tag_name) + expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('put_roundtrip_b', 'unko') + expect(tag.tag_name.aliases.map(&:name)).not_to include('spec_tag') + expect(alias_tn.reload.canonical).to eq(tag.tag_name) + + version = tag.tag_versions.order(:version_no).last + + expect(version.event_type).to eq('update') + expect(version.name).to eq('spec_tag') + expect(version.aliases.split).to contain_exactly('put_roundtrip_b', 'unko') + end + it 'parent tag の snapshot も作成する' do old_parent = Tag.create!( tag_name: TagName.create!(name: 'put_snapshot_old_parent'), @@ -1153,6 +1241,48 @@ RSpec.describe 'Tags API', type: :request do ) end + it 'wiki を持つ tag を旧 alias へ戻しても wiki を新 canonical へ移す' do + wiki_page = + Wiki::Commit.create_content!( + tag_name: tag.tag_name, + body: 'wiki body before', + created_by_user: member_user, + message: 'init') + + expect { + put "/tags/#{ tag.id }", params: { + name: 'put_wiki_roundtrip_b', + category: 'general', + aliases: 'unko', + parent_tags: '', + deprecated: '0', + } + + put "/tags/#{ tag.id }", params: { + name: 'spec_tag', + category: 'general', + aliases: 'unko', + parent_tags: '', + deprecated: '0', + } + } + .to change(TagVersion, :count).by(3) + .and change(WikiVersion, :count).by(2) + + expect(response).to have_http_status(:ok) + + tag.reload + + expect(wiki_page.reload.tag_name).to eq(tag.tag_name) + expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').wiki_page).to be_nil + expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').canonical).to eq(tag.tag_name) + + versions = wiki_page.wiki_versions.order(:version_no).last(2) + + expect(versions.map(&:event_type)).to eq(['update', 'update']) + expect(versions.map(&:title)).to eq(['put_wiki_roundtrip_b', 'spec_tag']) + end + it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do old_owner = Tag.create!( tag_name: TagName.create!(name: 'put_alias_old_owner'), @@ -1191,6 +1321,48 @@ RSpec.describe 'Tags API', type: :request do expect(old_owner_versions.second.aliases.split).not_to include('put_stolen_alias') end + it '別 tag の alias 名を rename で奪へる' do + old_owner = Tag.create!( + tag_name: TagName.create!(name: 'put_alias_collision_owner'), + category: :general + ) + stolen_alias = TagName.create!( + name: 'put_alias_collision_name', + canonical: old_owner.tag_name + ) + wiki_page = + Wiki::Commit.create_content!( + tag_name: tag.tag_name, + body: 'put collision wiki', + created_by_user: member_user, + message: 'init') + + put "/tags/#{ tag.id }", params: { + name: 'put_alias_collision_name', + category: 'general', + aliases: 'unko', + parent_tags: '', + deprecated: '0', + } + + expect(response).to have_http_status(:ok) + + tag.reload + old_owner.reload + stolen_alias.reload + + expect(tag.name).to eq('put_alias_collision_name') + expect(stolen_alias.canonical_id).to be_nil + expect(TagName.find_by!(name: 'spec_tag').canonical).to eq(tag.tag_name) + expect(old_owner.tag_name.aliases.map(&:name)).not_to include('put_alias_collision_name') + + old_owner_versions = old_owner.tag_versions.order(:version_no) + + expect(old_owner_versions.last.event_type).to eq('update') + expect(old_owner_versions.last.aliases.split).not_to include('put_alias_collision_name') + expect(wiki_page.reload.tag_name).to eq(tag.tag_name) + end + it 'parent_tags に指定すると循環する tag は 422 にする' do pending '#332 で対応予定'