|
|
@@ -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 |