| @@ -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 | |||||
| @@ -14,6 +14,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(:nico_tag) { create(:tag, :nico) } | let(:nico_tag) { create(:tag, :nico) } | ||||
| it '401 when not logged in' do | 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' } | patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' } | ||||
| expect(response).to have_http_status(:bad_request) | expect(response).to have_http_status(:bad_request) | ||||
| end | 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 | ||||
| end | end | ||||
| @@ -1131,4 +1131,52 @@ RSpec.describe 'Posts API', type: :request do | |||||
| expect(response).to have_http_status(:unprocessable_entity) | expect(response).to have_http_status(:unprocessable_entity) | ||||
| end | end | ||||
| 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 | end | ||||
| @@ -58,17 +58,49 @@ RSpec.describe "TagChildren", type: :request do | |||||
| end | end | ||||
| 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) } | before { stub_current_user(admin) } | ||||
| let(:parent_id) { -1 } | let(:parent_id) { -1 } | ||||
| let(:child_id) { -1 } | let(:child_id) { -1 } | ||||
| it "returns 204 (rescue nil)" do | |||||
| it "returns 404" do | |||||
| do_request | do_request | ||||
| 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 | ||||
| @@ -116,18 +148,58 @@ RSpec.describe "TagChildren", type: :request do | |||||
| expect(response).to have_http_status(:no_content) | expect(response).to have_http_status(:no_content) | ||||
| end | 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 | 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) } | before { stub_current_user(admin) } | ||||
| let(:parent_id) { -1 } | let(:parent_id) { -1 } | ||||
| let(:child_id) { -1 } | let(:child_id) { -1 } | ||||
| it "returns 204 (rescue nil)" do | |||||
| it "returns 404" do | |||||
| do_request | do_request | ||||
| 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 | ||||
| @@ -364,9 +364,70 @@ RSpec.describe 'Tags API', type: :request do | |||||
| expect(response.status).to be_in([404, 500]) | expect(response.status).to be_in([404, 500]) | ||||
| end | 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 | end | ||||
| end | end | ||||
| @@ -214,4 +214,108 @@ RSpec.describe "nico:sync" do | |||||
| expect(version.event_type).to eq('create') | expect(version.event_type).to eq('create') | ||||
| expect(version.tags).to eq(snapshot_tags(post.reload)) | expect(version.tags).to eq(snapshot_tags(post.reload)) | ||||
| end | 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('<html></html>')) | |||||
| 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('<html></html>')) | |||||
| 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('<html></html>')) | |||||
| 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 | end | ||||