diff --git a/backend/spec/models/version_record_spec.rb b/backend/spec/models/version_record_spec.rb new file mode 100644 index 0000000..d3acb34 --- /dev/null +++ b/backend/spec/models/version_record_spec.rb @@ -0,0 +1,74 @@ +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') } + + it 'makes TagVersion read only after create' do + version = TagVersion.create!( + tag: tag, + version_no: 1, + event_type: 'create', + name: tag.name, + category: tag.category, + aliases: '', + parent_tag_ids: '', + created_at: Time.current, + created_by_user: nil + ) + + expect { + version.update!(name: 'changed') + }.to raise_error(ActiveRecord::ReadOnlyRecord) + end + + it 'prevents TagVersion destroy' do + version = TagVersion.create!( + tag: tag, + version_no: 1, + event_type: 'create', + name: tag.name, + category: tag.category, + aliases: '', + parent_tag_ids: '', + created_at: Time.current, + created_by_user: nil + ) + + expect { + version.destroy! + }.to raise_error(ActiveRecord::ReadOnlyRecord) + end + + it 'makes NicoTagVersion read only after create' do + version = NicoTagVersion.create!( + tag: nico_tag, + version_no: 1, + event_type: 'create', + name: nico_tag.name, + linked_tags: '', + created_at: Time.current, + created_by_user: nil + ) + + expect { + version.update!(name: 'nico:changed') + }.to raise_error(ActiveRecord::ReadOnlyRecord) + end + + it 'prevents NicoTagVersion destroy' do + version = NicoTagVersion.create!( + tag: nico_tag, + version_no: 1, + event_type: 'create', + name: nico_tag.name, + linked_tags: '', + created_at: Time.current, + created_by_user: nil + ) + + expect { + version.destroy! + }.to raise_error(ActiveRecord::ReadOnlyRecord) + end +end diff --git a/backend/spec/requests/nico_tags_spec.rb b/backend/spec/requests/nico_tags_spec.rb index 6ee9479..26d5de0 100644 --- a/backend/spec/requests/nico_tags_spec.rb +++ b/backend/spec/requests/nico_tags_spec.rb @@ -14,6 +14,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) } it '401 when not logged in' do @@ -34,5 +35,59 @@ RSpec.describe 'NicoTags', type: :request do patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' } expect(response).to have_http_status(:bad_request) 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) + + linked_a_name = TagName.create!(name: 'nico_linked_a') + linked_a = Tag.create!(tag_name: linked_a_name, category: :general) + + 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) + + expect { + patch "/tags/nico/#{nico_tag.id}", params: { + tags: " #{linked_a.name}\n#{linked_b.name} " + } + }.to change(TagVersion, :count).by(2) + .and change(NicoTagVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + + names = json.map { |t| t['name'] } + expect(names).to match_array(['nico_linked_a', 'nico_linked_b']) + + linked_versions = TagVersion.where(tag: [linked_a, linked_b]).order(:tag_id) + expect(linked_versions.map(&:event_type)).to eq(['create', 'create']) + expect(linked_versions.map(&:created_by_user_id)).to all(eq(admin.id)) + + versions = nico_tag.reload.nico_tag_versions.order(:version_no) + expect(versions.map(&:event_type)).to eq(['create', 'update']) + expect(versions.last.linked_tags.split).to match_array([ + 'nico_linked_a', + 'nico_linked_b' + ]) + expect(versions.last.created_by_user_id).to eq(admin.id) + end + + it '400 when linked tag normalises to nico tag' 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) + + expect { + patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' } + }.not_to change(NicoTagVersion, :count) + + expect(response).to have_http_status(:bad_request) + end end end diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index a804bc8..3c59c9c 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -1131,4 +1131,52 @@ RSpec.describe 'Posts API', type: :request do expect(response).to have_http_status(:unprocessable_entity) end end + + describe 'tag versioning from post write actions' do + let(:member) { create(:user, :member) } + + it 'creates tag snapshot for normalised tags on POST /posts' do + sign_in_as(member) + + expect { + post '/posts', params: { + title: 'tag versioned post', + url: 'https://example.com/tag-versioned-post', + tags: 'spec_tag', + thumbnail: dummy_upload + } + }.to change { tag.reload.tag_versions.count }.by(1) + + expect(response).to have_http_status(:created) + + version = tag.reload.tag_versions.order(:version_no).last + expect(version.version_no).to eq(1) + expect(version.event_type).to eq('create') + expect(version.name).to eq('spec_tag') + expect(version.category).to eq('general') + expect(version.created_by_user_id).to eq(member.id) + end + + it 'creates tag snapshot for normalised tags on PUT /posts/:id' do + sign_in_as(member) + + tag_name2 = TagName.create!(name: 'spec_tag_2') + tag2 = Tag.create!(tag_name: tag_name2, category: :general) + + expect { + put "/posts/#{post_record.id}", params: { + title: 'updated title', + tags: 'spec_tag_2' + } + }.to change { tag2.reload.tag_versions.count }.by(1) + + expect(response).to have_http_status(:ok) + + version = tag2.reload.tag_versions.order(:version_no).last + expect(version.version_no).to eq(1) + expect(version.event_type).to eq('create') + expect(version.name).to eq('spec_tag_2') + expect(version.created_by_user_id).to eq(member.id) + end + end end diff --git a/backend/spec/requests/tag_children_spec.rb b/backend/spec/requests/tag_children_spec.rb index 6a93334..8e1b91b 100644 --- a/backend/spec/requests/tag_children_spec.rb +++ b/backend/spec/requests/tag_children_spec.rb @@ -58,17 +58,49 @@ RSpec.describe "TagChildren", type: :request do end end - context "when Tag.find raises (invalid ids) it still returns 204" do + context "when Tag.find raises (invalid ids)" do before { stub_current_user(admin) } let(:parent_id) { -1 } let(:child_id) { -1 } - it "returns 204 (rescue nil)" do + it "returns 404" do do_request 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 @@ -116,18 +148,58 @@ RSpec.describe "TagChildren", type: :request do expect(response).to have_http_status(:no_content) end + + it 'records create and update versions for child tag' do + expect { + do_request + }.to change(TagVersion, :count).by(2) + + expect(response).to have_http_status(:no_content) + + versions = child.reload.tag_versions.order(:version_no) + expect(versions.map(&:event_type)).to eq(['create', 'update']) + expect(versions.first.parent_tag_ids.split).to include(parent.id.to_s) + expect(versions.second.parent_tag_ids).to eq('') + expect(versions.second.created_by_user_id).to eq(admin.id) + end end - context "when Tag.find raises (invalid ids) it still returns 204" do + context "when Tag.find raises (invalid ids)" do before { stub_current_user(admin) } let(:parent_id) { -1 } let(:child_id) { -1 } - it "returns 204 (rescue nil)" do + it "returns 404" do do_request 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 9a140b1..42d3728 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -364,9 +364,70 @@ RSpec.describe 'Tags API', type: :request do expect(response.status).to be_in([404, 500]) end - it 'バリデーションで update! が失敗したら(通常は 422 か 500)' do - patch "/tags/#{ tag.id }", params: { name: 'new', category: 'nico' } - expect(response.status).to be_in([422, 500]) + it 'nico category への変更は 422 を返す' do + patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' } + + expect(response).to have_http_status(:unprocessable_entity) + expect(tag.reload.name).to eq('spec_tag') + expect(tag.category).to eq('general') + end + + it 'creates initial and update tag versions when name and category change' do + expect { + patch "/tags/#{tag.id}", params: { name: 'new_tag_name', category: 'meme' } + }.to change(TagVersion, :count).by(2) + + expect(response).to have_http_status(:ok) + + versions = tag.reload.tag_versions.order(:version_no) + + expect(versions.map(&:event_type)).to eq(['create', 'update']) + + expect(versions.first.name).to eq('spec_tag') + expect(versions.first.category).to eq('general') + expect(versions.first.aliases.split).to include('unko') + + expect(versions.second.name).to eq('new_tag_name') + expect(versions.second.category).to eq('meme') + expect(versions.second.created_by_user_id).to eq(member_user.id) + end + + it 'returns 422 when changing normal tag category to nico' do + expect { + patch "/tags/#{tag.id}", params: { category: 'nico' } + }.not_to change(TagVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + expect(tag.reload.category).to eq('general') + end + + it 'creates nico tag versions 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' } + }.to change(NicoTagVersion, :count).by(2) + + expect(response).to have_http_status(:ok) + + versions = nico_tag.reload.nico_tag_versions.order(:version_no) + expect(versions.map(&:event_type)).to eq(['create', 'update']) + expect(versions.first.name).to eq('nico:tags_spec_source') + expect(versions.second.name).to eq('nico:tags_spec_renamed') + expect(versions.second.created_by_user_id).to eq(member_user.id) + 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 end end diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index ff64490..27e63e5 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -214,4 +214,108 @@ RSpec.describe "nico:sync" do expect(version.event_type).to eq('create') expect(version.tags).to eq(snapshot_tags(post.reload)) end + + it '新規 nico tag に nico tag version を作る' do + Tag.bot + Tag.tagme + Tag.niconico + Tag.video + Tag.no_deerjikist + + stub_python([{ + 'code' => 'sm9', + 'title' => 't', + 'tags' => ['AAA'], + 'uploaded_at' => '2026-01-01 12:34:56' + }]) + + allow(URI).to receive(:open).and_return(StringIO.new('')) + + expect { + run_rake_task('nico:sync') + }.to change(NicoTagVersion, :count).by(1) + + nico_tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'nico:AAA' }) + version = nico_tag.nico_tag_versions.order(:version_no).last + + expect(version.version_no).to eq(1) + expect(version.event_type).to eq('create') + expect(version.name).to eq('nico:AAA') + expect(version.created_by_user).to be_nil + end + + it '既存 post に version が無い場合は create snapshot を補う' do + post = Post.create!( + title: 'old', + url: 'https://www.nicovideo.jp/watch/sm9', + uploaded_user: nil + ) + + kept_general = create_tag!('spec_kept_without_version', category: 'general') + PostTag.create!(post: post, tag: kept_general) + + Tag.bot + Tag.tagme + Tag.no_deerjikist + + stub_python([{ + 'code' => 'sm9', + 'title' => 'changed title', + 'tags' => ['AAA'], + 'uploaded_at' => '2026-01-01 12:34:56' + }]) + + allow(URI).to receive(:open).and_return(StringIO.new('')) + + expect { + run_rake_task('nico:sync') + }.to change { post.reload.post_versions.count }.by(1) + + versions = post.reload.post_versions.order(:version_no) + + expect(versions.map(&:event_type)).to eq(['create']) + expect(versions.first.title).to eq('changed title') + expect(versions.first.tags).to eq(snapshot_tags(post.reload)) + end + + it '既存 version がある post には update version を作る' do + post = Post.create!( + title: 'old', + url: 'https://www.nicovideo.jp/watch/sm9', + uploaded_user: nil + ) + + kept_general = create_tag!('spec_kept_with_version', category: 'general') + PostTag.create!(post: post, tag: kept_general) + + PostVersionRecorder.record!( + post: post, + event_type: :create, + created_by_user: nil + ) + + Tag.bot + Tag.tagme + Tag.no_deerjikist + + stub_python([{ + 'code' => 'sm9', + 'title' => 'changed title', + 'tags' => ['AAA'], + 'uploaded_at' => '2026-01-01 12:34:56' + }]) + + allow(URI).to receive(:open).and_return(StringIO.new('')) + + expect { + run_rake_task('nico:sync') + }.to change { post.reload.post_versions.count }.by(1) + + versions = post.reload.post_versions.order(:version_no) + + expect(versions.map(&:event_type)).to eq(['create', 'update']) + expect(versions.first.title).to eq('old') + expect(versions.second.title).to eq('changed title') + expect(versions.second.tags).to eq(snapshot_tags(post.reload)) + end end