From e0e7a22c38b9928c9e921d91bca42f36f6007984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Thu, 5 Mar 2026 20:35:11 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=8B=E3=82=B8=E3=83=A9=E3=83=BC=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=88#247=EF=BC=89=20(#275)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #247 #247 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/275 --- .../app/controllers/deerjikists_controller.rb | 47 +++++ backend/app/controllers/tags_controller.rb | 21 ++ backend/app/models/deerjikist.rb | 21 ++ backend/app/models/tag.rb | 9 + .../app/representations/deerjikist_repr.rb | 16 ++ .../{WikiPageRepr.rb => wiki_page_repr.rb} | 0 backend/config/routes.rb | 20 +- .../20260303122700_create_deerjikists.rb | 10 + backend/db/schema.rb | 11 +- backend/spec/factories/tags.rb | 4 +- backend/spec/requests/deerjikists_spec.rb | 181 ++++++++++++++++++ .../spec/requests/tags_deerjikists_spec.rb | 102 ++++++++++ 12 files changed, 438 insertions(+), 4 deletions(-) create mode 100644 backend/app/controllers/deerjikists_controller.rb create mode 100644 backend/app/models/deerjikist.rb create mode 100644 backend/app/representations/deerjikist_repr.rb rename backend/app/representations/{WikiPageRepr.rb => wiki_page_repr.rb} (100%) create mode 100644 backend/db/migrate/20260303122700_create_deerjikists.rb create mode 100644 backend/spec/requests/deerjikists_spec.rb create mode 100644 backend/spec/requests/tags_deerjikists_spec.rb diff --git a/backend/app/controllers/deerjikists_controller.rb b/backend/app/controllers/deerjikists_controller.rb new file mode 100644 index 0000000..b04eaf6 --- /dev/null +++ b/backend/app/controllers/deerjikists_controller.rb @@ -0,0 +1,47 @@ +class DeerjikistsController < ApplicationController + def show + platform = params[:platform].to_s.strip + code = params[:code].to_s.strip + return head :bad_request if platform.blank? || code.blank? + + deerjikist = Deerjikist + .joins(:tag) + .includes(tag: :tag_name) + .find_by(platform:, code:) + if deerjikist + render json: DeerjikistRepr.base(deerjikist) + else + head :not_found + end + end + + def update + return head :unauthorized unless current_user + return head :forbidden unless current_user.member? + + platform = params[:platform].to_s.strip + code = params[:code].to_s.strip + tag_id = params[:tag_id].to_i + return head :bad_request if platform.blank? || code.blank? || tag_id <= 0 + + deerjikist = Deerjikist.find_or_initialize_by(platform:, code:).tap do |d| + d.tag_id = tag_id + d.save! + end + + render json: DeerjikistRepr.base(deerjikist) + end + + def destroy + return head :unauthorized unless current_user + return head :forbidden unless current_user.member? + + platform = params[:platform].to_s.strip + code = params[:code].to_s.strip + return head :bad_request if platform.blank? || code.blank? + + Deerjikist.find([platform, code]).destroy! + + head :no_content + end +end diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index c4d5b0d..186b6b9 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -86,6 +86,27 @@ class TagsController < ApplicationController end end + def deerjikists + tag = Tag.joins(:tag_name) + .includes(:tag_name, tag_name: :wiki_page) + .find_by(id: params[:id]) + return head :not_found unless tag + + render json: DeerjikistRepr.many(tag.deerjikists) + end + + def deerjikists_by_name + name = params[:name].to_s.strip + return head :bad_request if name.blank? + + tag = Tag.joins(:tag_name) + .includes(:tag_name, tag_name: :wiki_page) + .find_by(tag_names: { name: }) + return head :not_found unless tag + + render json: DeerjikistRepr.many(tag.deerjikists) + end + def update return head :unauthorized unless current_user return head :forbidden unless current_user.member? diff --git a/backend/app/models/deerjikist.rb b/backend/app/models/deerjikist.rb new file mode 100644 index 0000000..8447ef4 --- /dev/null +++ b/backend/app/models/deerjikist.rb @@ -0,0 +1,21 @@ +class Deerjikist < ApplicationRecord + self.primary_key = :platform, :code + + belongs_to :tag + + validates :platform, presence: true + validates :code, presence: true + validates :tag_id, presence: true + + validate :tag_must_be_deerjikist + + enum :platform, nico: 'nico', youtube: 'youtube' + + private + + def tag_must_be_deerjikist + if tag && !(tag.deerjikist?) + errors.add :tag, 'タグはニジラー・カテゴリである必要があります.' + end + end +end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index ce064a4..14555a7 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -24,6 +24,8 @@ class Tag < ApplicationRecord has_many :tag_similarities, dependent: :delete_all + has_many :deerjikists, dependent: :delete_all + belongs_to :tag_name delegate :wiki_page, to: :tag_name @@ -42,6 +44,7 @@ class Tag < ApplicationRecord validate :nico_tag_name_must_start_with_nico validate :tag_name_must_be_canonical + validate :category_must_be_deerjikist_with_deerjikists scope :nico_tags, -> { where(category: :nico) } @@ -149,4 +152,10 @@ class Tag < ApplicationRecord errors.add :tag_name, 'tag_names へは実体を示す必要があります.' end end + + def category_must_be_deerjikist_with_deerjikists + if !(deerjikist?) && deerjikists.exists? + errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.' + end + end end diff --git a/backend/app/representations/deerjikist_repr.rb b/backend/app/representations/deerjikist_repr.rb new file mode 100644 index 0000000..fbb73c9 --- /dev/null +++ b/backend/app/representations/deerjikist_repr.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + + +module DeerjikistRepr + BASE = { only: [:platform, :code], include: { tag: TagRepr::BASE } }.freeze + + module_function + + def base deerjikist + deerjikist.as_json(BASE) + end + + def many deerjikists + deerjikists.map { |d| base(d) } + end +end diff --git a/backend/app/representations/WikiPageRepr.rb b/backend/app/representations/wiki_page_repr.rb similarity index 100% rename from backend/app/representations/WikiPageRepr.rb rename to backend/app/representations/wiki_page_repr.rb diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 0a52bf9..ed7b4ac 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -9,7 +9,15 @@ Rails.application.routes.draw do resources :tags, only: [:index, :show, :update] do collection do get :autocomplete - get 'name/:name', action: :show_by_name + + scope :name do + get ':name/deerjikists', action: :deerjikists_by_name + get ':name', action: :show_by_name + end + end + + member do + get :deerjikists end end @@ -54,4 +62,14 @@ Rails.application.routes.draw do post 'code/renew', action: :renew end end + + resources :deerjikists, only: [] do + collection do + scope ':platform/:code' do + get '', action: :show + put '', action: :update + delete '', action: :destroy + end + end + end end diff --git a/backend/db/migrate/20260303122700_create_deerjikists.rb b/backend/db/migrate/20260303122700_create_deerjikists.rb new file mode 100644 index 0000000..a87a963 --- /dev/null +++ b/backend/db/migrate/20260303122700_create_deerjikists.rb @@ -0,0 +1,10 @@ +class CreateDeerjikists < ActiveRecord::Migration[8.0] + def change + create_table :deerjikists, primary_key: [:platform, :code] do |t| + t.string :platform, null: false, limit: 16 + t.string :code, null: false + t.references :tag, null: false + t.timestamps + end + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 7a53c81..8077fac 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_01_27_005300) do +ActiveRecord::Schema[8.0].define(version: 2026_03_03_122700) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -39,6 +39,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_27_005300) do t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end + create_table "deerjikists", primary_key: ["platform", "code"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "platform", limit: 16, null: false + t.string "code", null: false + t.bigint "tag_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["tag_id"], name: "index_deerjikists_on_tag_id" + end + create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.binary "ip_address", limit: 16, null: false t.boolean "banned", default: false, null: false diff --git a/backend/spec/factories/tags.rb b/backend/spec/factories/tags.rb index 5d9530e..e2d1120 100644 --- a/backend/spec/factories/tags.rb +++ b/backend/spec/factories/tags.rb @@ -1,11 +1,11 @@ FactoryBot.define do factory :tag do - category { 'general' } + category { :general } post_count { 0 } association :tag_name trait :nico do - category { 'nico' } + category { :nico } tag_name { association(:tag_name, name: "nico:#{ SecureRandom.hex(4) }") } end end diff --git a/backend/spec/requests/deerjikists_spec.rb b/backend/spec/requests/deerjikists_spec.rb new file mode 100644 index 0000000..136fc2d --- /dev/null +++ b/backend/spec/requests/deerjikists_spec.rb @@ -0,0 +1,181 @@ +require 'rails_helper' + +RSpec.describe 'Deerjikists API', type: :request do + let(:platform) { 'nico' } + let(:code) { 'deerjika-bot' } + + let!(:tag1) { create(:tag, category: :deerjikist) } + let!(:tag2) { create(:tag, category: :deerjikist) } + + let(:member) { create(:user, :member) } + let(:guest) { create(:user, role: :guest) } + + describe 'GET /deerjikists/:platform/:code' do + subject(:do_request) do + get "/deerjikists/#{ platform }/#{ code }" + end + + context 'when deerjikist exists' do + before do + Deerjikist.create!(platform:, code:, tag: tag1) + end + + it 'returns 200 and deerjikist json' do + do_request + expect(response).to have_http_status(:ok) + + expect(json).to be_a(Hash) + expect(json['platform']).to eq(platform) + expect(json['code']).to eq(code) + + expect(json['tag']).to be_a(Hash) + expect(json['tag']['id']).to eq(tag1.id) + expect(json['tag']['name']).to eq(tag1.name) + end + end + + context 'when deerjikist does not exist' do + it 'returns 404' do + do_request + expect(response).to have_http_status(:not_found) + end + end + + context 'when platform or code become blank after strip' do + it 'returns 400' do + get '/deerjikists/%20/%20' + expect(response).to have_http_status(:bad_request) + end + end + end + + describe 'PUT /deerjikists/:platform/:code' do + subject(:do_request) do + put "/deerjikists/#{ platform }/#{ code }", params: payload + end + + let(:payload) { { tag_id: tag1.id } } + + context 'when not legged in' do + it 'returns 401' do + sign_out + do_request + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when logged in but not member' do + it 'returns 403' do + sign_in_as guest + do_request + expect(response).to have_http_status(:forbidden) + end + end + + context 'when member' do + before do + sign_in_as member + end + + context 'when params invalid' do + it 'returns 400 when tag_id is missing or invalid' do + put "/deerjikists/#{ platform }/#{ code }", params: { tag_id: 0 } + expect(response).to have_http_status(:bad_request) + end + + it 'returns 400 when platform or code blank after strip' do + put '/deerjikists/%20/%20', params: { tag_id: tag1.id } + expect(response).to have_http_status(:bad_request) + end + end + + context 'when creating new deerjikist' do + it 'creates and returns 200 with json' do + expect { do_request }.to change { Deerjikist.count }.by(1) + expect(response).to have_http_status(:ok) + + d = Deerjikist.find_by(platform:, code:) + expect(d).to be_present + expect(d.tag_id).to eq(tag1.id) + + expect(json['platform']).to eq(platform) + expect(json['code']).to eq(code) + end + end + + context 'when updating existing deerjikist' do + before do + Deerjikist.create!(platform:, code:, tag: tag1) + end + + let(:payload) { { tag_id: tag2.id } } + + it 'updates tag_id and returns 200' do + expect { do_request }.not_to change { Deerjikist.count } + expect(response).to have_http_status(:ok) + + d = Deerjikist.find_by(platform:, code:) + expect(d.tag_id).to eq(tag2.id) + + expect(json['platform']).to eq(platform) + expect(json['code']).to eq(code) + end + end + end + end + + describe 'DELETE /deerjikists/:platform/:code' do + subject(:do_request) do + delete "/deerjikists/#{ platform }/#{ code }" + end + + context 'when not logged in' do + it 'returns 401' do + sign_out + do_request + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when logged in but not member' do + it 'returns 403' do + sign_in_as guest + do_request + expect(response).to have_http_status(:forbidden) + end + end + + context 'when member' do + before do + sign_in_as member + end + + it 'returns 400 when platform/code blank after strip' do + delete '/deerjikists/%20/%20' + expect(response).to have_http_status(:bad_request) + end + + context 'when deerjikist exists' do + before do + Deerjikist.create!(platform: platform, code: code, tag: tag1) + end + + it 'destroys and returns 204' do + expect { + do_request + }.to change { Deerjikist.exists?(platform: platform, code: code) } + .from(true).to(false) + + expect(response).to have_http_status(:no_content) + end + end + + context 'when deerjikist does not exist' do + it 'returns 404' do + do_request + expect(response).to have_http_status(:not_found) + end + end + end + end +end diff --git a/backend/spec/requests/tags_deerjikists_spec.rb b/backend/spec/requests/tags_deerjikists_spec.rb new file mode 100644 index 0000000..4825f36 --- /dev/null +++ b/backend/spec/requests/tags_deerjikists_spec.rb @@ -0,0 +1,102 @@ +require 'rails_helper' + +RSpec.describe 'Tags deerjikists API', type: :request do + let(:platform1) { 'nico' } + let(:code1) { 'deerjika-bot' } + let(:platform2) { 'youtube' } + let(:code2) { 'deerjika-bot.bsky.social' } + + let!(:tag) { create(:tag, category: :deerjikist) } + + before do + # show_by_name / deerjikists_by_name 用に名前を固定 + tag.tag_name.update!(name: 'deerjika') + end + + describe 'GET /tags/:id/deerjikists' do + subject(:do_request) do + get "/tags/#{ tag_id }/deerjikists" + end + + let(:tag_id) { tag.id } + + context 'when tag exists and has no deerjikists' do + it 'returns 200 and empty array' do + do_request + expect(response).to have_http_status(:ok) + expect(json).to eq([]) + end + end + + context 'when tag exists and has deerjikists' do + before do + Deerjikist.create!(platform: platform1, code: code1, tag: tag) + Deerjikist.create!(platform: platform2, code: code2, tag: tag) + end + + it 'returns 200 and deerjikists array' do + do_request + expect(response).to have_http_status(:ok) + + expect(json).to be_a(Array) + expect(json.size).to eq(2) + + expect(json.map { |h| [h['platform'], h['code']] }).to contain_exactly( + [platform1, code1], + [platform2, code2], + ) + end + end + + context 'when tag does not exist' do + let(:tag_id) { 9_999_999 } + + it 'returns 404' do + do_request + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'GET /tags/name/:name/deerjikists' do + subject(:do_request) do + get "/tags/name/#{ name }/deerjikists" + end + + let(:name) { 'deerjika' } + + context 'when name is blank after strip' do + let(:name) { '%20' } + + it 'returns 400' do + do_request + expect(response).to have_http_status(:bad_request) + end + end + + context 'when tag does not exist for name' do + let(:name) { 'no-such-tag' } + + it 'returns 404' do + do_request + expect(response).to have_http_status(:not_found) + end + end + + context 'when tag exists and has deerjikists' do + before do + Deerjikist.create!(platform: platform1, code: code1, tag: tag) + end + + it 'returns 200 and deerjikists array' do + do_request + expect(response).to have_http_status(:ok) + + expect(json).to be_a(Array) + expect(json.size).to eq(1) + expect(json[0]['platform']).to eq(platform1) + expect(json[0]['code']).to eq(code1) + end + end + end +end