From c0d52077b9c7c2dbea8e20e878f217eecf517df9 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 6 Apr 2026 08:41:43 +0900 Subject: [PATCH] #99 --- .../app/controllers/materials_controller.rb | 29 +- backend/app/controllers/tags_controller.rb | 2 +- backend/app/representations/material_repr.rb | 12 +- backend/config/environments/production.rb | 4 +- backend/config/environments/test.rb | 2 + backend/spec/requests/materials_spec.rb | 378 ++++++++++++++++++ backend/spec/requests/tags_spec.rb | 151 +++++++ 7 files changed, 548 insertions(+), 30 deletions(-) create mode 100644 backend/spec/requests/materials_spec.rb diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index e467747..3452d0a 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -18,7 +18,7 @@ class MaterialsController < ApplicationController count = q.count materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset) - render json: { materials: materials.map { |m| material_json(m) }, count: count } + render json: { materials: MaterialRepr.many(materials), count: count } end def show @@ -29,11 +29,7 @@ class MaterialsController < ApplicationController .find_by(id: params[:id]) return head :not_found unless material - render json: material.as_json(methods: [:content_type]).merge( - file: if material.file.attached? - rails_storage_proxy_url(material.file, only_path: false) - end, - tag: TagRepr.base(material.tag)) + render json: MaterialRepr.base(material) end def create @@ -54,7 +50,7 @@ class MaterialsController < ApplicationController material.file.attach(file) if material.save - render json: material_json(material), status: :created + render json: MaterialRepr.base(material), status: :created else render json: { errors: material.errors.full_messages }, status: :unprocessable_entity end @@ -80,15 +76,11 @@ class MaterialsController < ApplicationController if file material.file.attach(file) else - material.file.purge(file) + material.file.purge end if material.save - render json: material.as_json(methods: [:content_type]).merge( - file: if material.file.attached? - rails_storage_proxy_url(material.file, only_path: false) - end, - tag: TagRepr.base(material.tag)) + render json: MaterialRepr.base(material) else render json: { errors: material.errors.full_messages }, status: :unprocessable_entity end @@ -104,15 +96,4 @@ class MaterialsController < ApplicationController material.discard head :no_content end - - private - - def material_json(material) - MaterialRepr.base(material).merge( - 'filename' => material.file.attached? ? material.file.filename.to_s : nil, - 'byte_size' => material.file.attached? ? material.file.byte_size : nil, - 'content_type' => material.file.attached? ? material.file.content_type : nil, - 'url' => material.file.attached? ? url_for(material.file) : nil - ) - end end diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 337d31c..3605ef8 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -204,7 +204,7 @@ class TagsController < ApplicationController tag = Tag.joins(:tag_name) .includes(:tag_name, tag_name: :wiki_page) .find_by(tag_names: { name: }) - return :not_found unless tag + return head :not_found unless tag render json: build_tag_children(tag) end diff --git a/backend/app/representations/material_repr.rb b/backend/app/representations/material_repr.rb index ed76450..f95cf25 100644 --- a/backend/app/representations/material_repr.rb +++ b/backend/app/representations/material_repr.rb @@ -2,13 +2,19 @@ module MaterialRepr - BASE = { only: [:id, :url, :parent_id, :created_at, :updated_at], - include: { created_by_user: UserRepr::BASE, tag: TagRepr::BASE } }.freeze + BASE = { methods: [:content_type], + include: { created_by_user: UserRepr::BASE, + updated_by_user: UserRepr::BASE } }.freeze module_function def base(material) - material.as_json(BASE) + material.as_json(BASE).merge( + file: if material.file.attached? + Rails.application.routes.url_helpers.rails_storage_proxy_url( + material.file, only_path: false) + end, + tag: TagRepr.base(material.tag)) end def many(materials) diff --git a/backend/config/environments/production.rb b/backend/config/environments/production.rb index 8477b2d..d1ad8cc 100644 --- a/backend/config/environments/production.rb +++ b/backend/config/environments/production.rb @@ -18,8 +18,8 @@ Rails.application.configure do # Enable serving of images, stylesheets, and JavaScripts from an asset server. # config.asset_host = "http://assets.example.com" - # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = :r2 + # TODO: オブスト契約したら :r2 に変更する. + config.active_storage.service = :local # Assume all access to the app is happening through a SSL-terminating reverse proxy. config.assume_ssl = true diff --git a/backend/config/environments/test.rb b/backend/config/environments/test.rb index c2095b1..1914d54 100644 --- a/backend/config/environments/test.rb +++ b/backend/config/environments/test.rb @@ -50,4 +50,6 @@ Rails.application.configure do # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true + + Rails.application.routes.default_url_options[:host] = 'www.example.com' end diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb new file mode 100644 index 0000000..f2cc27e --- /dev/null +++ b/backend/spec/requests/materials_spec.rb @@ -0,0 +1,378 @@ +require 'rails_helper' + +RSpec.describe 'Materials API', type: :request do + let!(:member_user) { create(:user, :member) } + let!(:guest_user) { create(:user) } + + def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy') + Rack::Test::UploadedFile.new(StringIO.new(body), type, original_filename: filename) + end + + def response_materials + json.fetch('materials') + end + + def build_material(tag:, user:, parent: nil, file: dummy_upload, url: nil) + Material.new(tag:, parent:, url:, created_by_user: user, updated_by_user: user).tap do |material| + material.file.attach(file) if file + material.save! + end + end + + describe 'GET /materials' do + let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) } + let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) } + + let!(:material_a) do + build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png')) + end + + let!(:material_b) do + build_material(tag: tag_b, user: member_user, parent: material_a, file: dummy_upload(filename: 'b.png')) + end + + before do + old_time = Time.zone.local(2026, 3, 29, 1, 0, 0) + new_time = Time.zone.local(2026, 3, 29, 2, 0, 0) + + material_a.update_columns(created_at: old_time, updated_at: old_time) + material_b.update_columns(created_at: new_time, updated_at: new_time) + end + + it 'returns materials with count and metadata' do + get '/materials' + + expect(response).to have_http_status(:ok) + expect(json).to include('materials', 'count') + expect(response_materials).to be_an(Array) + expect(json['count']).to eq(2) + + row = response_materials.find { |m| m['id'] == material_b.id } + expect(row).to be_present + expect(row['tag']).to include( + 'id' => tag_b.id, + 'name' => 'material_index_b', + 'category' => 'material' + ) + expect(row['created_by_user']).to include( + 'id' => member_user.id, + 'name' => member_user.name + ) + expect(row['content_type']).to eq('image/png') + end + + it 'filters materials by tag_id' do + get '/materials', params: { tag_id: material_a.tag_id } + + expect(response).to have_http_status(:ok) + expect(json['count']).to eq(1) + expect(response_materials.map { |m| m['id'] }).to eq([material_a.id]) + end + + it 'filters materials by parent_id' do + get '/materials', params: { parent_id: material_a.id } + + expect(response).to have_http_status(:ok) + expect(json['count']).to eq(1) + expect(response_materials.map { |m| m['id'] }).to eq([material_b.id]) + end + + it 'paginates and keeps total count' do + get '/materials', params: { page: 2, limit: 1 } + + expect(response).to have_http_status(:ok) + expect(json['count']).to eq(2) + expect(response_materials.size).to eq(1) + expect(response_materials.first['id']).to eq(material_a.id) + end + + it 'normalises invalid page and limit' do + get '/materials', params: { page: 0, limit: 0 } + + expect(response).to have_http_status(:ok) + expect(json['count']).to eq(2) + expect(response_materials.size).to eq(1) + expect(response_materials.first['id']).to eq(material_b.id) + end + end + + describe 'GET /materials/:id' do + let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) } + let!(:material) do + build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png')) + end + + it 'returns a material with file, tag, and content_type' do + get "/materials/#{ material.id }" + + expect(response).to have_http_status(:ok) + expect(json).to include( + 'id' => material.id, + 'content_type' => 'image/png' + ) + expect(json['file']).to be_present + expect(json['tag']).to include( + 'id' => tag.id, + 'name' => 'material_show', + 'category' => 'material' + ) + end + + it 'returns 404 when material does not exist' do + get '/materials/999999999' + expect(response).to have_http_status(:not_found) + end + end + + describe 'POST /materials' do + context 'when not logged in' do + before { sign_out } + + it 'returns 401' do + post '/materials', params: { + tag: 'material_create_unauthorized', + file: dummy_upload + } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when logged in' do + before { sign_in_as(guest_user) } + + it 'returns 400 when tag is blank' do + post '/materials', params: { tag: ' ', file: dummy_upload } + + expect(response).to have_http_status(:bad_request) + end + + it 'returns 400 when both file and url are blank' do + post '/materials', params: { tag: 'material_create_blank' } + + expect(response).to have_http_status(:bad_request) + end + + it 'creates a material with an attached file' do + expect do + post '/materials', params: { + tag: 'material_create_new', + file: dummy_upload(filename: 'created.png') + } + end.to change(Material, :count).by(1) + .and change(Tag, :count).by(1) + .and change(TagName, :count).by(1) + + expect(response).to have_http_status(:created) + + material = Material.order(:id).last + expect(material.tag.name).to eq('material_create_new') + expect(material.tag.category).to eq('material') + expect(material.created_by_user).to eq(guest_user) + expect(material.updated_by_user).to eq(guest_user) + expect(material.file.attached?).to be(true) + + expect(json['id']).to eq(material.id) + expect(json.dig('tag', 'name')).to eq('material_create_new') + expect(json['content_type']).to eq('image/png') + end + + it 'returns 422 when the existing tag is not material/character' do + general_tag_name = TagName.create!(name: 'material_create_general_tag') + Tag.create!(tag_name: general_tag_name, category: :general) + + post '/materials', params: { + tag: 'material_create_general_tag', + file: dummy_upload + } + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'persists url-only material' do + expect do + post '/materials', params: { + tag: 'material_create_url_only', + url: 'https://example.com/material-source' + } + end.to change(Material, :count).by(1) + + expect(response).to have_http_status(:created) + + material = Material.order(:id).last + expect(material.tag.name).to eq('material_create_url_only') + expect(material.url).to eq('https://example.com/material-source') + expect(material.file.attached?).to be(false) + end + + it 'returns the original url for url-only material' do + post '/materials', params: { + tag: 'material_create_url_only_response', + url: 'https://example.com/material-source' + } + + expect(response).to have_http_status(:created) + expect(json['url']).to eq('https://example.com/material-source') + end + end + end + + describe 'PUT /materials/:id' do + let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) } + let!(:material) do + build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png')) + end + + context 'when not logged in' do + before { sign_out } + + it 'returns 401' do + put "/materials/#{ material.id }", params: { + tag: 'material_update_new', + file: dummy_upload(filename: 'new.png') + } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when logged in but not member' do + before { sign_in_as(guest_user) } + + it 'returns 403' do + put "/materials/#{ material.id }", params: { + tag: 'material_update_new', + file: dummy_upload(filename: 'new.png') + } + + expect(response).to have_http_status(:forbidden) + end + end + + context 'when member' do + before { sign_in_as(member_user) } + + it 'returns 404 when material does not exist' do + put '/materials/999999999', params: { + tag: 'material_update_missing', + file: dummy_upload + } + + expect(response).to have_http_status(:not_found) + end + + it 'returns 400 when tag is blank' do + put "/materials/#{ material.id }", params: { + tag: ' ', + file: dummy_upload + } + + expect(response).to have_http_status(:bad_request) + end + + it 'returns 400 when both file and url are blank' do + put "/materials/#{ material.id }", params: { + tag: 'material_update_no_payload' + } + + expect(response).to have_http_status(:bad_request) + end + + it 'updates tag, url, file, and updated_by_user' do + old_blob_id = material.file.blob.id + + put "/materials/#{ material.id }", params: { + tag: 'material_update_new', + url: 'https://example.com/updated-source', + file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg') + } + + expect(response).to have_http_status(:ok) + + material.reload + expect(material.tag.name).to eq('material_update_new') + expect(material.tag.category).to eq('material') + expect(material.url).to eq('https://example.com/updated-source') + expect(material.updated_by_user).to eq(member_user) + expect(material.file.attached?).to be(true) + expect(material.file.blob.id).not_to eq(old_blob_id) + expect(material.file.blob.filename.to_s).to eq('updated.jpg') + expect(material.file.blob.content_type).to eq('image/jpeg') + + expect(json['id']).to eq(material.id) + expect(json['file']).to be_present + expect(json['content_type']).to eq('image/jpeg') + expect(json.dig('tag', 'name')).to eq('material_update_new') + end + + it 'purges the existing file when file is omitted and url is provided' do + old_blob_id = material.file.blob.id + + put "/materials/#{ material.id }", params: { + tag: 'material_update_remove_file', + url: 'https://example.com/updated-source' + } + + expect(response).to have_http_status(:ok) + + material.reload + expect(material.tag.name).to eq('material_update_remove_file') + expect(material.url).to eq('https://example.com/updated-source') + expect(material.updated_by_user).to eq(member_user) + expect(material.file.attached?).to be(false) + + expect( + ActiveStorage::Blob.where(id: old_blob_id).exists? + ).to be(false) + + expect(json['id']).to eq(material.id) + expect(json['file']).to be_nil + expect(json['content_type']).to be_nil + expect(json.dig('tag', 'name')).to eq('material_update_remove_file') + expect(json['url']).to eq('https://example.com/updated-source') + end + end + end + + describe 'DELETE /materials/:id' do + let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) } + let!(:material) do + build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png')) + end + + context 'when not logged in' do + before { sign_out } + + it 'returns 401' do + delete "/materials/#{ material.id }" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when logged in but not member' do + before { sign_in_as(guest_user) } + + it 'returns 403' do + delete "/materials/#{ material.id }" + expect(response).to have_http_status(:forbidden) + end + end + + context 'when member' do + before { sign_in_as(member_user) } + + it 'returns 404 when material does not exist' do + delete '/materials/999999999' + expect(response).to have_http_status(:not_found) + end + + it 'discards the material and returns 204' do + delete "/materials/#{ material.id }" + + expect(response).to have_http_status(:no_content) + expect(Material.find_by(id: material.id)).to be_nil + expect(Material.with_discarded.find(material.id)).to be_discarded + end + end + end +end diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 8dfa51a..9a140b1 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -19,6 +19,17 @@ RSpec.describe 'Tags API', type: :request do response_tags.map { |t| t.fetch('name') } end + def dummy_material_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy') + Rack::Test::UploadedFile.new(StringIO.new(body), type, original_filename: filename) + end + + def create_material(tag, user:, filename: 'dummy.png', type: 'image/png', url: nil) + Material.new(tag:, url:, created_by_user: user, updated_by_user: user).tap do |material| + material.file.attach(dummy_material_upload(filename:, type:)) if filename + material.save! + end + end + describe 'GET /tags' do it 'returns tags with count and metadata' do get '/tags' @@ -359,4 +370,144 @@ RSpec.describe 'Tags API', type: :request do end end end + + describe 'GET /tags/with-depth' do + let!(:root_meme) do + Tag.create!(tag_name: TagName.create!(name: 'depth_a_root_meme'), category: :meme) + end + + let!(:root_material) do + Tag.create!(tag_name: TagName.create!(name: 'depth_b_root_material'), category: :material) + end + + let!(:hidden_general_root) do + Tag.create!(tag_name: TagName.create!(name: 'depth_hidden_general_root'), category: :general) + end + + let!(:child_character) do + Tag.create!(tag_name: TagName.create!(name: 'depth_child_character'), category: :character) + end + + let!(:grandchild_material) do + Tag.create!(tag_name: TagName.create!(name: 'depth_grandchild_material'), category: :material) + end + + let!(:child_general) do + Tag.create!(tag_name: TagName.create!(name: 'depth_child_general'), category: :general) + end + + before do + TagImplication.create!(parent_tag: root_meme, tag: child_character) + TagImplication.create!(parent_tag: child_character, tag: grandchild_material) + TagImplication.create!(parent_tag: root_material, tag: child_general) + end + + it 'returns only visible root tags and visible has_children flags' do + get '/tags/with-depth' + + expect(response).to have_http_status(:ok) + expect(json.map { |t| t['name'] }).to eq([ + 'depth_a_root_meme', + 'depth_b_root_material' + ]) + + meme_row = json.find { |t| t['name'] == 'depth_a_root_meme' } + material_row = json.find { |t| t['name'] == 'depth_b_root_material' } + + expect(meme_row['has_children']).to eq(true) + expect(meme_row['children']).to eq([]) + + expect(material_row['has_children']).to eq(false) + expect(material_row['children']).to eq([]) + + expect(json.map { |t| t['name'] }).not_to include('depth_hidden_general_root') + end + + it 'returns children of the specified parent' do + get '/tags/with-depth', params: { parent: root_meme.id } + + expect(response).to have_http_status(:ok) + expect(json.map { |t| t['name'] }).to eq(['depth_child_character']) + + row = json.first + expect(row['category']).to eq('character') + expect(row['has_children']).to eq(true) + expect(row['children']).to eq([]) + end + end + + describe 'GET /tags/name/:name/materials' do + let!(:material_user) { create_member_user! } + + let!(:root_tag) do + Tag.create!(tag_name: TagName.create!(name: 'materials_root'), category: :material) + end + + let!(:child_a_tag) do + Tag.create!(tag_name: TagName.create!(name: 'materials_child_a'), category: :material) + end + + let!(:child_b_tag) do + Tag.create!(tag_name: TagName.create!(name: 'materials_child_b'), category: :character) + end + + let!(:grandchild_tag) do + Tag.create!(tag_name: TagName.create!(name: 'materials_grandchild'), category: :material) + end + + let!(:root_material) do + create_material(root_tag, user: material_user, filename: 'root.png') + end + + let!(:child_a_material) do + create_material(child_a_tag, user: material_user, filename: 'child_a.png') + end + + let!(:grandchild_material) do + create_material(grandchild_tag, user: material_user, filename: 'grandchild.png') + end + + before do + TagImplication.create!(parent_tag: root_tag, tag: child_b_tag) + TagImplication.create!(parent_tag: root_tag, tag: child_a_tag) + TagImplication.create!(parent_tag: child_a_tag, tag: grandchild_tag) + end + + it 'returns a tag tree with nested materials sorted by child name' do + get "/tags/name/#{ CGI.escape(root_tag.name) }/materials" + + expect(response).to have_http_status(:ok) + + expect(json).to include( + 'id' => root_tag.id, + 'name' => 'materials_root', + 'category' => 'material' + ) + + expect(json['material']).to be_present + expect(json.dig('material', 'id')).to eq(root_material.id) + expect(json.dig('material', 'file')).to be_present + expect(json.dig('material', 'content_type')).to eq('image/png') + + expect(json['children'].map { |t| t['name'] }).to eq([ + 'materials_child_a', + 'materials_child_b' + ]) + + child_a = json['children'].find { |t| t['name'] == 'materials_child_a' } + child_b = json['children'].find { |t| t['name'] == 'materials_child_b' } + + expect(child_a.dig('material', 'id')).to eq(child_a_material.id) + expect(child_a['children'].map { |t| t['name'] }).to eq(['materials_grandchild']) + expect(child_a.dig('children', 0, 'material', 'id')).to eq(grandchild_material.id) + + expect(child_b['material']).to be_nil + expect(child_b['children']).to eq([]) + end + + it 'returns 404 when the tag does not exist' do + get '/tags/name/no_such_tag_12345/materials' + expect(response).to have_http_status(:not_found) + end + end end