require 'rails_helper' RSpec.describe 'Materials API', type: :request do include ActiveJob::TestHelper let!(:member_user) { create(:user, :member) } let!(:admin_user) { create(:user, :admin) } 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) do Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) end let!(:tag_b) do Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) end 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['content_type']).to eq('image/png') expect(row['media_kind']).to eq('image') end it 'filters materials by q' do get '/materials', params: { q: 'material_index_a' } 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 tag_state' do untagged = build_material(tag: nil, user: member_user, file: dummy_upload(filename: 'untagged.png')) get '/materials', params: { tag_state: 'untagged' } expect(response).to have_http_status(:ok) expect(json['count']).to eq(1) expect(response_materials.map { |m| m['id'] }).to eq([untagged.id]) end it 'filters materials by media_kind' do get '/materials', params: { media_kind: 'image' } expect(response).to have_http_status(:ok) expect(json['count']).to eq(2) expect(response_materials.map { |m| m['media_kind'] }).to all(eq('image')) 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) do Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) end 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 but not member' do before { sign_in_as(guest_user) } it 'returns 403' do post '/materials', params: { tag: 'material_create_guest_forbidden', file: dummy_upload } expect(response).to have_http_status(:forbidden) end end context 'when member' do before { sign_in_as(member_user) } it 'returns 422 when tag is blank' do post '/materials', params: { tag: ' ', file: dummy_upload } expect(response).to have_http_status(:unprocessable_entity) expect(json.fetch('errors')).to include( 'tag' => ['タグは必須です.']) end it 'returns 422 when both file and url are blank' do post '/materials', params: { tag: 'material_create_blank' } expect(response).to have_http_status(:unprocessable_entity) expect(json.fetch('errors')).to include( 'file' => ['ファイルまたは URL は必須です.'], 'url' => ['ファイルまたは URL は必須です.']) 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'), export_paths: { legacy_drive: '伊地知ニジカ/created.png' } } end.to change(Material, :count).by(1) .and change(Tag, :count).by(1) .and change(TagName, :count).by(1) .and change(MaterialVersion, :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(member_user) expect(material.updated_by_user).to eq(member_user) expect(material.file.attached?).to be(true) expect(material.version_no).to eq(1) expect(material.material_versions.first.event_type).to eq('create') expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/created.png') expect(material.material_versions.first.export_paths_json).to eq( 'legacy_drive' => '伊地知ニジカ/created.png' ) 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') expect(json.dig('export_paths', 'legacy_drive')).to eq('伊地知ニジカ/created.png') end it 'snapshots attached file metadata and sha256' do post '/materials', params: { tag: 'material_create_file_version', file: dummy_upload(filename: 'created.png', body: 'sha-body') } expect(response).to have_http_status(:created) version = Material.order(:id).last.material_versions.first expect(version.file_blob_id).to be_present expect(version.file_filename).to eq('created.png') expect(version.file_content_type).to eq('image/png') expect(version.file_byte_size).to eq('sha-body'.bytesize) expect(version.file_sha256).to eq(Digest::SHA256.hexdigest('sha-body')) 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 it 'rejects sha256-blocked file upload' do sha256 = Digest::SHA256.hexdigest('blocked-body') MaterialImportBlock.create!(match_kind: 'sha256', sha256:, reason: 'copyright_high_risk', created_by_user: admin_user) expect do post '/materials', params: { tag: 'material_blocked_create', file: dummy_upload(filename: 'blocked.png', body: 'blocked-body') } end.not_to change(Material, :count) expect(response).to have_http_status(:unprocessable_entity) expect(json.fetch('errors')).to include( 'file' => ['抑止された素材です: copyright_high_risk'] ) end end end describe 'PUT /materials/:id' do let!(:tag) do Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) end 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 422 when tag is blank' do put "/materials/#{ material.id }", params: { tag: ' ', file: dummy_upload } expect(response).to have_http_status(:unprocessable_entity) expect(json.fetch('errors')).to include( 'tag' => ['タグは必須です.']) end it 'keeps the existing file when file and url are omitted' do put "/materials/#{ material.id }", params: { tag: 'material_update_no_payload' } expect(response).to have_http_status(:ok) expect(material.reload.file.attached?).to be(true) end it 'updates tag, url, file, and updated_by_user' do old_blob_id = material.file.blob.id expect do put "/materials/#{ material.id }", params: { tag: 'material_update_new', url: 'https://example.com/updated-source', file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg'), export_paths: { legacy_drive: '伊地知ニジカ/updated.jpg' } } end.to change(MaterialVersion, :count).by(2) 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(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true) expect(material.file.blob.filename.to_s).to eq('updated.jpg') expect(material.file.blob.content_type).to eq('image/jpeg') expect(material.version_no).to eq(2) expect(material.material_versions.order(:version_no).last.event_type).to eq('update') expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/updated.jpg') expect(material.material_versions.order(:version_no).last.export_paths_json).to eq( 'legacy_drive' => '伊地知ニジカ/updated.jpg' ) 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 'detaches the existing file without purging blob when url replaces file' 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(true) 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 it 'does not increase version for the same snapshot update' do MaterialVersionRecorder.record!(material:, event_type: :create, created_by_user: member_user) expect do put "/materials/#{ material.id }", params: { tag: 'material_update_old' } end.not_to change(MaterialVersion, :count) expect(response).to have_http_status(:ok) expect(material.reload.version_no).to eq(1) end it 'records update version when only export_path changes' do MaterialVersionRecorder.record!(material:, event_type: :create, created_by_user: member_user) expect do put "/materials/#{ material.id }", params: { tag: 'material_update_old', export_paths: { legacy_drive: '素材/only-path.png' } } end.to change(MaterialVersion, :count).by(1) expect(response).to have_http_status(:ok) expect(material.reload.material_export_items.first.export_path).to eq('素材/only-path.png') expect(material.material_versions.order(:version_no).last.export_paths_json).to eq( 'legacy_drive' => '素材/only-path.png' ) end it 'removes export_path item when blank is submitted' do MaterialExportItem.create!(material:, profile: 'legacy_drive', export_path: '素材/remove.png', created_by_user: member_user) MaterialVersionRecorder.record!(material:, event_type: :create, created_by_user: member_user) expect do put "/materials/#{ material.id }", params: { tag: 'material_update_old', export_paths: { legacy_drive: '' } } end.to change(MaterialExportItem, :count).by(-1) .and change(MaterialVersion, :count).by(1) expect(response).to have_http_status(:ok) expect(material.reload.material_export_items).to be_empty expect(material.material_versions.order(:version_no).last.export_paths_json).to eq({}) end it 'rejects sha256-blocked replacement file' do sha256 = Digest::SHA256.hexdigest('blocked-update') MaterialImportBlock.create!(match_kind: 'sha256', sha256:, reason: 'source_owner_request', created_by_user: admin_user) put "/materials/#{ material.id }", params: { tag: 'material_update_old', file: dummy_upload(filename: 'blocked.png', body: 'blocked-update') } expect(response).to have_http_status(:unprocessable_entity) expect(material.reload.file.blob.filename.to_s).to eq('old.png') end end end describe 'GET /materials/download.zip' do let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'zip_a'), category: :material) } let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'zip_b'), category: :material) } let!(:material_a) do build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png', body: 'zip-a')) end let!(:material_b) do build_material(tag: tag_b, user: member_user, file: dummy_upload(filename: 'b.png', body: 'zip-b')) end before do MaterialExportItem.create!(material: material_a, profile: 'legacy_drive', export_path: '素材/a.png', created_by_user: member_user) MaterialExportItem.create!(material: material_b, profile: 'legacy_drive', export_path: '素材/b.png', created_by_user: member_user) end it 'uses material_export_items.export_path as ZIP entry paths' do get '/materials/download.zip', params: { profile: 'legacy_drive' } expect(response).to have_http_status(:ok) expect(response.media_type).to eq('application/zip') expect(response.body.b).to include('素材/a.png'.b) expect(response.body.b).to include('素材/b.png'.b) end it 'filters by tag_id' do get '/materials/download.zip', params: { profile: 'legacy_drive', tag_id: tag_a.id } expect(response).to have_http_status(:ok) expect(response.body.b).to include('素材/a.png'.b) expect(response.body.b).not_to include('素材/b.png'.b) end it 'does not include disabled export items' do material_b.material_export_items.first.update!(enabled: false) get '/materials/download.zip', params: { profile: 'legacy_drive' } expect(response).to have_http_status(:ok) expect(response.body.b).to include('素材/a.png'.b) expect(response.body.b).not_to include('素材/b.png'.b) end end describe 'GET /materials/versions' do let!(:tag) do Tag.create!(tag_name: TagName.create!(name: 'material_history'), category: :material) end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'history.png')) end it 'returns material versions in reverse chronological order' do MaterialVersionRecorder.record!(material:, event_type: :create, created_by_user: member_user) material.update!(url: 'https://example.com/history') MaterialVersionRecorder.record!(material:, event_type: :update, created_by_user: admin_user) get '/materials/versions', params: { material_id: material.id } expect(response).to have_http_status(:ok) expect(json['count']).to eq(2) versions = json.fetch('versions') expect(versions.map { |v| v['version_no'] }).to eq([2, 1]) expect(versions.first).to include( 'material_id' => material.id, 'event_type' => 'update', 'tag_name' => 'material_history', 'file_filename' => 'history.png', 'url' => 'https://example.com/history' ) expect(versions.first['created_by_user']).to include('id' => admin_user.id) end it 'filters material versions by tag and event_type' do MaterialVersionRecorder.record!(material:, event_type: :create, created_by_user: member_user) get '/materials/versions', params: { tag: 'material_history', event_type: 'create' } expect(response).to have_http_status(:ok) expect(json.fetch('versions').map { |v| v['material_id'] }).to include(material.id) expect(json.fetch('versions').map { |v| v['event_type'] }).to all(eq('create')) end end describe 'material sync suppressions' do it 'requires member permission for index' do sign_out get '/materials/suppressions' expect(response).to have_http_status(:unauthorized) end it 'creates source suppression as member' do sign_in_as(member_user) expect do post '/materials/suppressions', params: { source_kind: 'google_drive_path_prefix', drive_path: '伊地知ニジカ/危険素材', reason: 'copyright_high_risk' } end.to change(MaterialSyncSuppression, :count).by(1) expect(response).to have_http_status(:created) expect(json).to include( 'source_kind' => 'google_drive_path_prefix', 'drive_path' => '伊地知ニジカ/危険素材', 'normalized_source_key' => 'google_drive_path_prefix:伊地知ニジカ/危険素材' ) end end describe 'DELETE /materials/:id' do let!(:tag) do Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) end 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