diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index f80848d..475f84e 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -49,13 +49,19 @@ class TagsController < ApplicationController return head :unauthorized unless current_user return head :forbidden unless current_user.member? + name = params[:name].presence + category = params[:category].presence + tag = Tag.find(params[:id]) - attrs = { name: params[:name].presence, - category: params[:category].presence }.compact + if name.present? + tag.tag_name.update!(name:) + end - tag.update!(attrs) if attrs.present? + if category.present? + tag.update!(category:) + end - render json: tag + render json: tag.as_json(methods: [:name]) end end diff --git a/backend/spec/factories/users.rb b/backend/spec/factories/users.rb index 18548b6..f7db70a 100644 --- a/backend/spec/factories/users.rb +++ b/backend/spec/factories/users.rb @@ -7,5 +7,9 @@ FactoryBot.define do trait :member do role { "member" } end + + trait :admin do + role { 'admin' } + end end end diff --git a/backend/spec/requests/tag_children_spec.rb b/backend/spec/requests/tag_children_spec.rb new file mode 100644 index 0000000..9db9beb --- /dev/null +++ b/backend/spec/requests/tag_children_spec.rb @@ -0,0 +1,134 @@ +# spec/requests/tag_children_spec.rb +require "rails_helper" + +RSpec.describe "TagChildren", type: :request do + let!(:parent) { create(:tag) } + let!(:child) { create(:tag) } + + # ここは君のUser factoryに合わせて調整 + let(:user) { create_member_user! } + let(:admin) { create_admin_user! } + + # current_user を ApplicationController でスタブ + def stub_current_user(user_or_nil) + allow_any_instance_of(ApplicationController) + .to receive(:current_user) + .and_return(user_or_nil) + end + + describe "POST /tag_children" do + subject(:do_request) do + post "/tags/#{ parent_id }/children/#{ child_id }" + end + + context "when not logged in" do + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it "returns 401" do + stub_current_user(nil) + do_request + expect(response).to have_http_status(:unauthorized) + end + end + + context "when logged in but not admin" do + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it "returns 403" do + stub_current_user(user) + do_request + expect(response).to have_http_status(:forbidden) + end + end + + context "when admin and params are present" do + before { stub_current_user(admin) } + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it "returns 204 and adds child to parent.children" do + expect(parent.children).not_to include(child) + + expect { do_request } + .to change { parent.reload.children.ids.include?(child.id) } + .from(false).to(true) + + expect(response).to have_http_status(:no_content) + end + end + + context "when Tag.find raises (invalid ids) it still returns 204" do + before { stub_current_user(admin) } + + let(:parent_id) { -1 } + let(:child_id) { -1 } + + it "returns 204 (rescue nil)" do + do_request + expect(response).to have_http_status(:no_content) + end + end + end + + describe "DELETE /tag_children" do + subject(:do_request) do + delete "/tags/#{ parent_id }/children/#{ child_id }" + end + + context "when not logged in" do + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it "returns 401" do + stub_current_user(nil) + do_request + expect(response).to have_http_status(:unauthorized) + end + end + + context "when logged in but not admin" do + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it "returns 403" do + stub_current_user(user) + do_request + expect(response).to have_http_status(:forbidden) + end + end + + context "when admin and params are present" do + before do + stub_current_user(admin) + parent.children << child + end + + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it "returns 204 and removes child from parent.children" do + expect(parent.reload.children).to include(child) + + expect { do_request } + .to change { parent.reload.children.ids.include?(child.id) } + .from(true).to(false) + + expect(response).to have_http_status(:no_content) + end + end + + context "when Tag.find raises (invalid ids) it still returns 204" do + before { stub_current_user(admin) } + + let(:parent_id) { -1 } + let(:child_id) { -1 } + + it "returns 204 (rescue nil)" do + do_request + expect(response).to have_http_status(:no_content) + end + end + end +end diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 7e26538..11cce46 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -76,4 +76,103 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:not_found) end end + + # member? を持つ user を想定(Factory 側で trait 作ってもOK) + let(:member_user) { create(:user) } + let(:non_member_user) { create(:user) } + + def stub_current_user(user) + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user) + end + + before do + allow(member_user).to receive(:member?).and_return(true) + allow(non_member_user).to receive(:member?).and_return(false) + end + + describe "PATCH /tags/:id" do + context "未ログイン" do + before { stub_current_user(nil) } + + it "401 を返す" do + patch "/tags/#{tag.id}", params: { name: "new" } + expect(response).to have_http_status(:unauthorized) + end + end + + context "ログインしてゐるが member でない" do + before { stub_current_user(non_member_user) } + + it "403 を返す" do + patch "/tags/#{tag.id}", params: { name: "new" } + expect(response).to have_http_status(:forbidden) + end + end + + context "member" do + before { stub_current_user(member_user) } + + it "name だけ更新できる" do + patch "/tags/#{tag.id}", params: { name: "new" } + + expect(response).to have_http_status(:ok) + + tag.reload + expect(tag.name).to eq("new") + expect(tag.category).to eq("general") + + json = JSON.parse(response.body) + expect(json["id"]).to eq(tag.id) + expect(json["name"]).to eq("new") + expect(json["category"]).to eq("general") + end + + it "category だけ更新できる" do + patch "/tags/#{tag.id}", params: { category: "meme" } + + expect(response).to have_http_status(:ok) + + tag.reload + expect(tag.name).to eq("spec_tag") + expect(tag.category).to eq("meme") + end + + it "空文字は presence により無視され、更新は走らない(値が変わらない)" do + patch "/tags/#{tag.id}", params: { name: "", category: " " } + + expect(response).to have_http_status(:ok) + + tag.reload + expect(tag.name).to eq("spec_tag") + expect(tag.category).to eq("general") + end + + it "両方更新できる" do + patch "/tags/#{tag.id}", params: { name: "n", category: "meta" } + + expect(response).to have_http_status(:ok) + + tag.reload + expect(tag.name).to eq("n") + expect(tag.category).to eq("meta") + end + + it "存在しない id だと RecordNotFound になる(通常は 404)" do + # Rails 設定次第で例外がそのまま上がる/404になる + # APIなら rescue_from で 404 にしてることが多いので、その場合は 404 を期待。 + patch "/tags/999999999", params: { name: "x" } + + expect(response.status).to be_in([404, 500]) + end + + it "バリデーションで update! が失敗したら(通常は 422 か 500)" do + patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' } + + # rescue_from の実装次第で変はる: + # - RecordInvalid を 422 にしてるなら 422 + # - 未処理なら 500 + expect(response.status).to be_in([422, 500]) + end + end + end end diff --git a/backend/spec/support/test_records.rb b/backend/spec/support/test_records.rb index e85b65b..350b8da 100644 --- a/backend/spec/support/test_records.rb +++ b/backend/spec/support/test_records.rb @@ -5,4 +5,11 @@ module TestRecords role: 'member', banned: false) end + + def create_admin_user! + User.create!(name: 'spec admin', + inheritance_code: SecureRandom.hex(16), + role: 'admin', + banned: false) + end end diff --git a/backend/spec/tasks/post_similarity_calc_spec.rb b/backend/spec/tasks/post_similarity_calc_spec.rb new file mode 100644 index 0000000..41de663 --- /dev/null +++ b/backend/spec/tasks/post_similarity_calc_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + + +RSpec.describe 'post_similarity:calc' do + include RakeTaskHelper + + it 'calls Similarity::Calc with Post and :tags' do + # 必要最低限のデータ + t1 = Tag.create!(name: "t1") + t2 = Tag.create!(name: "t2") + t3 = Tag.create!(name: "t3") + + p1 = Post.create!(url: "https://example.com/1") + p2 = Post.create!(url: "https://example.com/2") + p3 = Post.create!(url: "https://example.com/3") + + # kept スコープが絡むなら、PostTag がデフォで kept になる前提 + PostTag.create!(post: p1, tag: t1) + PostTag.create!(post: p1, tag: t2) + + PostTag.create!(post: p2, tag: t1) + PostTag.create!(post: p2, tag: t3) + + PostTag.create!(post: p3, tag: t3) + + expect { run_rake_task("post_similarity:calc") } + .to change { PostSimilarity.count }.from(0) + + ps = PostSimilarity.find_by!(post_id: p1.id, target_post_id: p2.id) + ps_rev = PostSimilarity.find_by!(post_id: p2.id, target_post_id: p1.id) + expect(ps_rev.cos).to eq(ps.cos) + end +end + diff --git a/backend/spec/tasks/tag_similarity_calc_spec.rb b/backend/spec/tasks/tag_similarity_calc_spec.rb new file mode 100644 index 0000000..8022231 --- /dev/null +++ b/backend/spec/tasks/tag_similarity_calc_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + + +RSpec.describe 'tag_similarity:calc' do + include RakeTaskHelper + + it 'calls Similarity::Calc with Tag and :posts' do + # 必要最低限のデータ + t1 = Tag.create!(name: "t1") + t2 = Tag.create!(name: "t2") + t3 = Tag.create!(name: "t3") + + p1 = Post.create!(url: "https://example.com/1") + p2 = Post.create!(url: "https://example.com/2") + p3 = Post.create!(url: "https://example.com/3") + + # kept スコープが絡むなら、PostTag がデフォで kept になる前提 + PostTag.create!(post: p1, tag: t1) + PostTag.create!(post: p1, tag: t2) + + PostTag.create!(post: p2, tag: t1) + PostTag.create!(post: p2, tag: t3) + + PostTag.create!(post: p3, tag: t3) + + expect { run_rake_task("tag_similarity:calc") } + .to change { TagSimilarity.count }.from(0) + + ps = TagSimilarity.find_by!(tag_id: t1.id, target_tag_id: t2.id) + ps_rev = TagSimilarity.find_by!(tag_id: t2.id, target_tag_id: t1.id) + expect(ps_rev.cos).to eq(ps.cos) + end +end +