Merge remote-tracking branch 'origin/main' into feature/351
このコミットが含まれているのは:
@@ -0,0 +1,57 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe MaterialExportItem, type: :model do
|
||||
let(:user) { create(:user, :member) }
|
||||
let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'export_item'), category: :material) }
|
||||
let(:material) do
|
||||
Material.create!(tag:, url: 'https://example.com/material',
|
||||
created_by_user: user, updated_by_user: user)
|
||||
end
|
||||
|
||||
it 'rejects blank export_path' do
|
||||
item = described_class.new(material:, profile: 'legacy_drive', export_path: '')
|
||||
|
||||
expect(item).not_to be_valid
|
||||
expect(item.errors[:export_path]).to be_present
|
||||
end
|
||||
|
||||
it 'rejects absolute export_path' do
|
||||
item = described_class.new(material:, profile: 'legacy_drive',
|
||||
export_path: '/素材/a.png')
|
||||
|
||||
expect(item).not_to be_valid
|
||||
expect(item.errors[:export_path]).to be_present
|
||||
end
|
||||
|
||||
it 'rejects parent traversal export_path' do
|
||||
item = described_class.new(material:, profile: 'legacy_drive',
|
||||
export_path: '素材/../a.png')
|
||||
|
||||
expect(item).not_to be_valid
|
||||
expect(item.errors[:export_path]).to be_present
|
||||
end
|
||||
|
||||
it 'rejects double slash export_path' do
|
||||
item = described_class.new(material:, profile: 'legacy_drive',
|
||||
export_path: '素材//a.png')
|
||||
|
||||
expect(item).not_to be_valid
|
||||
expect(item.errors[:export_path]).to be_present
|
||||
end
|
||||
|
||||
it 'rejects dot segment export_path' do
|
||||
item = described_class.new(material:, profile: 'legacy_drive',
|
||||
export_path: './素材/a.png')
|
||||
|
||||
expect(item).not_to be_valid
|
||||
expect(item.errors[:export_path]).to be_present
|
||||
end
|
||||
|
||||
it 'rejects trailing slash export_path' do
|
||||
item = described_class.new(material:, profile: 'legacy_drive',
|
||||
export_path: '素材/a/')
|
||||
|
||||
expect(item).not_to be_valid
|
||||
expect(item.errors[:export_path]).to be_present
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Material, type: :model do
|
||||
let(:user) { create(:user, :member) }
|
||||
|
||||
it 'allows only material source kinds, not suppression prefix kinds' do
|
||||
material = described_class.new(url: 'https://example.com/material',
|
||||
source_kind: 'google_drive_path_prefix',
|
||||
source_path: '素材/危険',
|
||||
created_by_user: user,
|
||||
updated_by_user: user)
|
||||
|
||||
expect(material).not_to be_valid
|
||||
expect(material.errors[:source_kind]).to be_present
|
||||
|
||||
material.source_kind = 'google_drive_path'
|
||||
expect(material).to be_valid
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TagImplication, type: :model do
|
||||
it 'rejects a parent tag that would create a cycle' do
|
||||
child = create(:tag, name: 'tag_implication_cycle_child')
|
||||
parent = create(:tag, name: 'tag_implication_cycle_parent')
|
||||
|
||||
described_class.create!(tag: child, parent_tag: parent)
|
||||
|
||||
implication = described_class.new(tag: parent, parent_tag: child)
|
||||
|
||||
expect(implication).not_to be_valid
|
||||
expect(implication.errors[:parent_tag_id]).to include(
|
||||
'親タグに子孫タグを指定すると循環します.'
|
||||
)
|
||||
expect(implication.errors[:base]).to be_present
|
||||
end
|
||||
|
||||
it 'terminates even when existing data already contains a cycle' do
|
||||
child = create(:tag, name: 'tag_implication_existing_cycle_child')
|
||||
parent = create(:tag, name: 'tag_implication_existing_cycle_parent')
|
||||
ancestor = create(:tag, name: 'tag_implication_existing_cycle_ancestor')
|
||||
|
||||
described_class.create!(tag: parent, parent_tag: ancestor)
|
||||
described_class.insert_all!(
|
||||
[
|
||||
{ tag_id: ancestor.id, parent_tag_id: parent.id,
|
||||
created_at: Time.current, updated_at: Time.current }
|
||||
]
|
||||
)
|
||||
|
||||
implication = described_class.new(tag: child, parent_tag: parent)
|
||||
|
||||
expect(implication).to be_valid
|
||||
end
|
||||
end
|
||||
@@ -143,7 +143,17 @@ RSpec.describe Tag, type: :model do
|
||||
first = create(:tag, name: 'expand_cycle_first')
|
||||
second = create(:tag, name: 'expand_cycle_second')
|
||||
TagImplication.create!(tag: first, parent_tag: second)
|
||||
TagImplication.create!(tag: second, parent_tag: first)
|
||||
now = Time.current
|
||||
TagImplication.insert_all!(
|
||||
[
|
||||
{
|
||||
tag_id: second.id,
|
||||
parent_tag_id: first.id,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second)
|
||||
end
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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')
|
||||
@@ -13,22 +16,29 @@ RSpec.describe 'Materials API', type: :request do
|
||||
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.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!(: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'))
|
||||
build_material(tag: tag_b, user: member_user, parent: material_a,
|
||||
file: dummy_upload(filename: 'b.png'))
|
||||
end
|
||||
|
||||
before do
|
||||
@@ -54,27 +64,36 @@ RSpec.describe 'Materials API', type: :request do
|
||||
'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')
|
||||
expect(row['media_kind']).to eq('image')
|
||||
end
|
||||
|
||||
it 'filters materials by tag_id' do
|
||||
get '/materials', params: { tag_id: material_a.tag_id }
|
||||
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 parent_id' do
|
||||
get '/materials', params: { parent_id: material_a.id }
|
||||
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([material_b.id])
|
||||
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
|
||||
@@ -94,10 +113,74 @@ RSpec.describe 'Materials API', type: :request do
|
||||
expect(response_materials.size).to eq(1)
|
||||
expect(response_materials.first['id']).to eq(material_b.id)
|
||||
end
|
||||
|
||||
it 'filters by descendant tags and returns stable parent tag groups' do
|
||||
root =
|
||||
Tag.create!(tag_name: TagName.create!(name: 'material_scope_root'),
|
||||
category: :material)
|
||||
child_b =
|
||||
Tag.create!(tag_name: TagName.create!(name: 'material_scope_b'),
|
||||
category: :material)
|
||||
child_a =
|
||||
Tag.create!(tag_name: TagName.create!(name: 'material_scope_a'),
|
||||
category: :material)
|
||||
deprecated =
|
||||
Tag.create!(tag_name: TagName.create!(name: 'material_scope_old'),
|
||||
category: :material,
|
||||
deprecated_at: Time.current)
|
||||
grandchild =
|
||||
Tag.create!(tag_name: TagName.create!(name: 'material_scope_grandchild'),
|
||||
category: :material)
|
||||
root_material =
|
||||
build_material(tag: root, user: member_user,
|
||||
file: dummy_upload(filename: 'root.png'))
|
||||
child_a_material =
|
||||
build_material(tag: child_a, user: member_user,
|
||||
file: dummy_upload(filename: 'child_a.png'))
|
||||
grandchild_material =
|
||||
build_material(tag: grandchild, user: member_user,
|
||||
file: dummy_upload(filename: 'grandchild.png'))
|
||||
|
||||
TagImplication.create!(parent_tag: root, tag: child_b)
|
||||
TagImplication.create!(parent_tag: root, tag: child_a)
|
||||
TagImplication.create!(parent_tag: child_b, tag: deprecated)
|
||||
TagImplication.create!(parent_tag: deprecated, tag: grandchild)
|
||||
|
||||
get '/materials',
|
||||
params: { tag_id: root.id, include_descendants: '1',
|
||||
group_by: 'parent_tag', sort: 'id', direction: 'asc' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response_materials.map { |m| m['id'] })
|
||||
.to include(root_material.id, child_a_material.id, grandchild_material.id)
|
||||
expect(json['tag_scope']).to eq(
|
||||
'tag' => {
|
||||
'id' => root.id,
|
||||
'name' => 'material_scope_root',
|
||||
'category' => 'material'
|
||||
},
|
||||
'include_descendants' => true
|
||||
)
|
||||
expect(json['groups'].map { |group| group.dig('tag', 'name') })
|
||||
.to eq(['material_scope_a', 'material_scope_b', 'material_scope_root'])
|
||||
expect(json['groups'].find { |group| group.dig('tag', 'id') == child_b.id }
|
||||
.fetch('material_ids')).to eq([grandchild_material.id])
|
||||
end
|
||||
|
||||
it 'returns nil tag_scope when tag_id is unknown' do
|
||||
get '/materials', params: { tag_id: 999_999, group_by: 'parent_tag' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json['tag_scope']).to be_nil
|
||||
expect(json['groups']).to eq([])
|
||||
expect(response_materials).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /materials/:id' do
|
||||
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) }
|
||||
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
|
||||
@@ -138,9 +221,22 @@ RSpec.describe 'Materials API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when logged in' do
|
||||
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 }
|
||||
|
||||
@@ -162,24 +258,49 @@ RSpec.describe 'Materials API', type: :request do
|
||||
expect do
|
||||
post '/materials', params: {
|
||||
tag: 'material_create_new',
|
||||
file: dummy_upload(filename: 'created.png')
|
||||
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(guest_user)
|
||||
expect(material.updated_by_user).to eq(guest_user)
|
||||
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
|
||||
@@ -219,11 +340,33 @@ RSpec.describe 'Materials API', type: :request do
|
||||
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) { Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) }
|
||||
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
|
||||
@@ -277,25 +420,26 @@ RSpec.describe 'Materials API', type: :request do
|
||||
'tag' => ['タグは必須です.'])
|
||||
end
|
||||
|
||||
it 'returns 422 when both file and url are blank' do
|
||||
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(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'file' => ['ファイルまたは URL は必須です.'],
|
||||
'url' => ['ファイルまたは URL は必須です.'])
|
||||
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
|
||||
|
||||
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 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)
|
||||
|
||||
@@ -306,8 +450,15 @@ RSpec.describe 'Materials API', type: :request do
|
||||
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
|
||||
@@ -315,7 +466,7 @@ RSpec.describe 'Materials API', type: :request do
|
||||
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
|
||||
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: {
|
||||
@@ -331,9 +482,7 @@ RSpec.describe 'Materials API', type: :request do
|
||||
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(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true)
|
||||
|
||||
expect(json['id']).to eq(material.id)
|
||||
expect(json['file']).to be_nil
|
||||
@@ -341,11 +490,205 @@ RSpec.describe 'Materials API', type: :request do
|
||||
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) { Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) }
|
||||
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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
require 'rails_helper'
|
||||
require 'base64'
|
||||
require 'set'
|
||||
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
@@ -8,6 +9,11 @@ RSpec.describe 'Posts API', type: :request do
|
||||
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
|
||||
before do
|
||||
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
|
||||
allow(Post).to receive(:resized_thumbnail_attachment).and_return(
|
||||
io: StringIO.new('dummy'),
|
||||
filename: 'resized_thumbnail.jpg',
|
||||
content_type: 'image/jpeg'
|
||||
)
|
||||
end
|
||||
|
||||
def create_nico_tag!(name)
|
||||
@@ -19,6 +25,18 @@ RSpec.describe 'Posts API', type: :request do
|
||||
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
|
||||
end
|
||||
|
||||
def real_thumbnail_upload
|
||||
gif =
|
||||
Base64.decode64(
|
||||
'R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')
|
||||
|
||||
Rack::Test::UploadedFile.new(
|
||||
StringIO.new(gif),
|
||||
'image/gif',
|
||||
original_filename: 'thumbnail.gif'
|
||||
)
|
||||
end
|
||||
|
||||
def post_write_params params = { }
|
||||
{ parent_post_ids: '' }.merge(params)
|
||||
end
|
||||
@@ -713,6 +731,66 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(json['tags'][0]).to have_key('name')
|
||||
end
|
||||
|
||||
it '201 when posting manually with a thumbnail' do
|
||||
sign_in_as(member)
|
||||
allow(Post).to receive(:resized_thumbnail_attachment).and_call_original
|
||||
|
||||
post '/posts', params: post_write_params(
|
||||
title: 'thumbnail post',
|
||||
url: 'https://example.com/thumbnail-post',
|
||||
tags: 'spec_tag',
|
||||
thumbnail: real_thumbnail_upload
|
||||
)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
post_record = Post.find(json.fetch('id'))
|
||||
expect(post_record.thumbnail).to be_attached
|
||||
expect(post_record.thumbnail.blob.content_type).to eq('image/jpeg')
|
||||
expect { post_record.thumbnail.download }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'resizes the thumbnail before the create transaction begins' do
|
||||
sign_in_as(member)
|
||||
|
||||
open_transactions = []
|
||||
baseline_open_transactions = Post.connection.open_transactions
|
||||
allow(Post).to receive(:resized_thumbnail_attachment) do |_upload|
|
||||
open_transactions << Post.connection.open_transactions
|
||||
{ io: StringIO.new('dummy'),
|
||||
filename: 'resized_thumbnail.jpg',
|
||||
content_type: 'image/jpeg' }
|
||||
end
|
||||
|
||||
post '/posts', params: post_write_params(
|
||||
title: 'transaction post',
|
||||
url: 'https://example.com/transaction-post',
|
||||
tags: 'spec_tag',
|
||||
thumbnail: dummy_upload
|
||||
)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
expect(open_transactions).to eq([baseline_open_transactions])
|
||||
end
|
||||
|
||||
it 'returns 422 and does not create a post when thumbnail resize fails' do
|
||||
sign_in_as(member)
|
||||
allow(Post).to receive(:resized_thumbnail_attachment).and_raise(MiniMagick::Error)
|
||||
|
||||
expect {
|
||||
post '/posts', params: post_write_params(
|
||||
title: 'broken thumbnail post',
|
||||
url: 'https://example.com/broken-thumbnail-post',
|
||||
tags: 'spec_tag',
|
||||
thumbnail: dummy_upload
|
||||
)
|
||||
}.not_to change(Post, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'thumbnail' => ['サムネイル画像の変換に失敗しました.']
|
||||
)
|
||||
end
|
||||
|
||||
it '201 and creates post + tags when member and tags have aliases' do
|
||||
sign_in_as(member)
|
||||
|
||||
|
||||
@@ -56,6 +56,17 @@ RSpec.describe "TagChildren", type: :request do
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'returns 422 and does not create relation when the new link makes a cycle' do
|
||||
TagImplication.create!(tag: parent, parent_tag: child)
|
||||
|
||||
expect {
|
||||
do_request
|
||||
}.not_to change(TagImplication, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(TagImplication.where(tag: child, parent_tag: parent)).not_to exist
|
||||
end
|
||||
end
|
||||
|
||||
context "when Tag.find raises (invalid ids)" do
|
||||
|
||||
@@ -581,6 +581,58 @@ RSpec.describe 'Tags API', type: :request do
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count)
|
||||
end
|
||||
|
||||
it 'full update で旧名 alias が残った tag を PATCH で旧名へ戻せる' do
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
name: 'patch_roundtrip_target',
|
||||
category: 'general',
|
||||
aliases: 'unko',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(TagName.find_by!(name: 'spec_tag').canonical).to eq(tag.reload.tag_name)
|
||||
|
||||
patch "/tags/#{ tag.id }", params: { name: 'spec_tag' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
tag.reload
|
||||
|
||||
expect(tag.name).to eq('spec_tag')
|
||||
expect(tag.tag_name.canonical_id).to be_nil
|
||||
expect(TagName.find_by!(name: 'patch_roundtrip_target').canonical).to eq(tag.tag_name)
|
||||
end
|
||||
|
||||
it '別 tag の正規名には変更できない' do
|
||||
wiki_page =
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: tag.tag_name,
|
||||
body: 'patch collision wiki',
|
||||
created_by_user: member_user,
|
||||
message: 'init')
|
||||
|
||||
patch "/tags/#{ tag.id }", params: { name: 'unknown' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'name' => ['その名前は既に使はれてゐます.']
|
||||
)
|
||||
|
||||
expect(tag.reload.name).to eq('spec_tag')
|
||||
expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('unko')
|
||||
expect(wiki_page.reload.tag_name).to eq(tag.tag_name)
|
||||
end
|
||||
|
||||
it 'system tag の name は変更できない' do
|
||||
system_tag = Tag.bot
|
||||
|
||||
patch "/tags/#{ system_tag.id }", params: { name: 'patch_system_tag_renamed' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(system_tag.reload.name).to eq('bot操作')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -745,7 +797,17 @@ RSpec.describe 'Tags API', type: :request do
|
||||
)
|
||||
TagImplication.create!(tag: first, parent_tag: root_material)
|
||||
TagImplication.create!(tag: second, parent_tag: first)
|
||||
TagImplication.create!(tag: first, parent_tag: second)
|
||||
now = Time.current
|
||||
TagImplication.insert_all!(
|
||||
[
|
||||
{
|
||||
tag_id: first.id,
|
||||
parent_tag_id: second.id,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
get '/tags/with-depth', params: { parent: root_material.id }
|
||||
|
||||
@@ -1029,6 +1091,42 @@ RSpec.describe 'Tags API', type: :request do
|
||||
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
||||
end
|
||||
|
||||
it '同じ tag の旧 alias へ戻しても rename できる' do
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
name: 'put_roundtrip_b',
|
||||
category: 'general',
|
||||
aliases: 'unko',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
name: 'spec_tag',
|
||||
category: 'general',
|
||||
aliases: 'unko',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
tag.reload
|
||||
|
||||
expect(tag.name).to eq('spec_tag')
|
||||
expect(TagName.find_by!(name: 'put_roundtrip_b').canonical).to eq(tag.tag_name)
|
||||
expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('put_roundtrip_b', 'unko')
|
||||
expect(tag.tag_name.aliases.map(&:name)).not_to include('spec_tag')
|
||||
expect(alias_tn.reload.canonical).to eq(tag.tag_name)
|
||||
|
||||
version = tag.tag_versions.order(:version_no).last
|
||||
|
||||
expect(version.event_type).to eq('update')
|
||||
expect(version.name).to eq('spec_tag')
|
||||
expect(version.aliases.split).to contain_exactly('put_roundtrip_b', 'unko')
|
||||
end
|
||||
|
||||
it 'parent tag の snapshot も作成する' do
|
||||
old_parent = Tag.create!(
|
||||
tag_name: TagName.create!(name: 'put_snapshot_old_parent'),
|
||||
@@ -1153,6 +1251,48 @@ RSpec.describe 'Tags API', type: :request do
|
||||
)
|
||||
end
|
||||
|
||||
it 'wiki を持つ tag を旧 alias へ戻しても wiki を新 canonical へ移す' do
|
||||
wiki_page =
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: tag.tag_name,
|
||||
body: 'wiki body before',
|
||||
created_by_user: member_user,
|
||||
message: 'init')
|
||||
|
||||
expect {
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
name: 'put_wiki_roundtrip_b',
|
||||
category: 'general',
|
||||
aliases: 'unko',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
name: 'spec_tag',
|
||||
category: 'general',
|
||||
aliases: 'unko',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
}
|
||||
.to change(TagVersion, :count).by(3)
|
||||
.and change(WikiVersion, :count).by(2)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
tag.reload
|
||||
|
||||
expect(wiki_page.reload.tag_name).to eq(tag.tag_name)
|
||||
expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').wiki_page).to be_nil
|
||||
expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').canonical).to eq(tag.tag_name)
|
||||
|
||||
versions = wiki_page.wiki_versions.order(:version_no).last(2)
|
||||
|
||||
expect(versions.map(&:event_type)).to eq(['update', 'update'])
|
||||
expect(versions.map(&:title)).to eq(['put_wiki_roundtrip_b', 'spec_tag'])
|
||||
end
|
||||
|
||||
it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
|
||||
old_owner = Tag.create!(
|
||||
tag_name: TagName.create!(name: 'put_alias_old_owner'),
|
||||
@@ -1191,9 +1331,49 @@ RSpec.describe 'Tags API', type: :request do
|
||||
expect(old_owner_versions.second.aliases.split).not_to include('put_stolen_alias')
|
||||
end
|
||||
|
||||
it 'parent_tags に指定すると循環する tag は 422 にする' do
|
||||
pending '#332 で対応予定'
|
||||
it '別 tag の alias 名を rename で奪へる' do
|
||||
old_owner = Tag.create!(
|
||||
tag_name: TagName.create!(name: 'put_alias_collision_owner'),
|
||||
category: :general
|
||||
)
|
||||
stolen_alias = TagName.create!(
|
||||
name: 'put_alias_collision_name',
|
||||
canonical: old_owner.tag_name
|
||||
)
|
||||
wiki_page =
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: tag.tag_name,
|
||||
body: 'put collision wiki',
|
||||
created_by_user: member_user,
|
||||
message: 'init')
|
||||
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
name: 'put_alias_collision_name',
|
||||
category: 'general',
|
||||
aliases: 'unko',
|
||||
parent_tags: '',
|
||||
deprecated: '0',
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
tag.reload
|
||||
old_owner.reload
|
||||
stolen_alias.reload
|
||||
|
||||
expect(tag.name).to eq('put_alias_collision_name')
|
||||
expect(stolen_alias.canonical_id).to be_nil
|
||||
expect(TagName.find_by!(name: 'spec_tag').canonical).to eq(tag.tag_name)
|
||||
expect(old_owner.tag_name.aliases.map(&:name)).not_to include('put_alias_collision_name')
|
||||
|
||||
old_owner_versions = old_owner.tag_versions.order(:version_no)
|
||||
|
||||
expect(old_owner_versions.last.event_type).to eq('update')
|
||||
expect(old_owner_versions.last.aliases.split).not_to include('put_alias_collision_name')
|
||||
expect(wiki_page.reload.tag_name).to eq(tag.tag_name)
|
||||
end
|
||||
|
||||
it 'parent_tags に指定すると循環する tag は 422 にする' do
|
||||
child = Tag.create!(
|
||||
tag_name: TagName.create!(name: 'put_cycle_child'),
|
||||
category: :general
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe MaterialFileSha256 do
|
||||
describe '.metadata_sha256' do
|
||||
it 'reads sha256 from Hash metadata' do
|
||||
blob = instance_double(ActiveStorage::Blob, metadata: { 'sha256' => 'abc123' })
|
||||
|
||||
expect(described_class.metadata_sha256(blob)).to eq('abc123')
|
||||
end
|
||||
|
||||
it 'reads sha256 from JSON string metadata' do
|
||||
blob = instance_double(ActiveStorage::Blob, metadata: '{"sha256":"abc123"}')
|
||||
|
||||
expect(described_class.metadata_sha256(blob)).to eq('abc123')
|
||||
end
|
||||
|
||||
it 'returns nil for invalid metadata' do
|
||||
blob = instance_double(ActiveStorage::Blob, metadata: '{')
|
||||
|
||||
expect(described_class.metadata_sha256(blob)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,128 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe MaterialSyncImporter do
|
||||
let(:user) { create(:user, :member) }
|
||||
let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'sync_tag'), category: :material) }
|
||||
|
||||
def tempfile_for body
|
||||
Tempfile.new(['material-sync-importer', '.png']).tap do |file|
|
||||
file.binmode
|
||||
file.write(body)
|
||||
file.rewind
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates an untagged material from a sync candidate with file_downloader' do
|
||||
result =
|
||||
described_class.import!(
|
||||
source_kind: 'google_drive_file',
|
||||
source_path: '素材/a.png',
|
||||
source_file_id: 'drive-file-a',
|
||||
normalized_source_key: 'google_drive_file:drive-file-a',
|
||||
filename: 'a.png',
|
||||
content_type: 'image/png',
|
||||
file_sha256: Digest::SHA256.hexdigest('sync-body'),
|
||||
file_downloader: -> { tempfile_for('sync-body') },
|
||||
export_path: '素材/a.png',
|
||||
tag: nil,
|
||||
url: nil,
|
||||
created_by_user: user,
|
||||
updated_by_user: user)
|
||||
|
||||
material = result.material.reload
|
||||
expect(result.action).to eq(:imported)
|
||||
expect(material.tag).to be_nil
|
||||
expect(material.url).to be_nil
|
||||
expect(material.file).to be_attached
|
||||
expect(material.file.blob.filename.to_s).to eq('a.png')
|
||||
expect(material.material_export_items.first.export_path).to eq('素材/a.png')
|
||||
end
|
||||
|
||||
it 'does not clear a human-assigned tag or URL on existing Drive sync' do
|
||||
material =
|
||||
Material.create!(tag:, url: 'https://example.com/human',
|
||||
source_kind: 'google_drive_file',
|
||||
source_path: '素材/a.png',
|
||||
source_file_id: 'drive-file-a',
|
||||
created_by_user: user,
|
||||
updated_by_user: user)
|
||||
material.file.attach(
|
||||
io: StringIO.new('same-body'),
|
||||
filename: 'a.png',
|
||||
content_type: 'image/png')
|
||||
material.file.blob.metadata['sha256'] = Digest::SHA256.hexdigest('same-body')
|
||||
material.file.blob.save!
|
||||
MaterialExportItem.create!(material:,
|
||||
profile: 'legacy_drive',
|
||||
export_path: '素材/a.png',
|
||||
created_by_user: user)
|
||||
|
||||
result =
|
||||
described_class.import!(
|
||||
source_kind: 'google_drive_file',
|
||||
source_path: '素材/a.png',
|
||||
source_file_id: 'drive-file-a',
|
||||
filename: 'a.png',
|
||||
content_type: 'image/png',
|
||||
file_sha256: Digest::SHA256.hexdigest('same-body'),
|
||||
export_path: '素材/a.png',
|
||||
tag: nil,
|
||||
url: nil,
|
||||
updated_by_user: user)
|
||||
|
||||
expect(result.action).to eq(:unchanged)
|
||||
expect(material.reload.tag).to eq(tag)
|
||||
expect(material.url).to eq('https://example.com/human')
|
||||
end
|
||||
|
||||
it 'suppresses a Google Drive candidate by relative path prefix' do
|
||||
MaterialSyncSuppression.create!(source_kind: 'google_drive_path_prefix',
|
||||
drive_path: '素材/危険',
|
||||
reason: 'copyright_high_risk',
|
||||
created_by_user: user)
|
||||
|
||||
result =
|
||||
described_class.import!(
|
||||
source_kind: 'google_drive_file',
|
||||
source_path: '素材/危険/a.png',
|
||||
source_file_id: 'drive-file-b',
|
||||
filename: 'a.png',
|
||||
content_type: 'image/png',
|
||||
file_sha256: Digest::SHA256.hexdigest('blocked-body'),
|
||||
file_downloader: -> { tempfile_for('blocked-body') },
|
||||
export_path: '素材/危険/a.png',
|
||||
updated_by_user: user)
|
||||
|
||||
expect(result.action).to eq(:suppressed)
|
||||
expect(result.suppression.normalized_source_key)
|
||||
.to eq('google_drive_path_prefix:素材/危険')
|
||||
expect(Material.find_by(source_file_id: 'drive-file-b')).to be_nil
|
||||
end
|
||||
|
||||
it 'rechecks import blocks after downloaded file sha256 is known' do
|
||||
MaterialImportBlock.create!(match_kind: 'sha256',
|
||||
sha256: Digest::SHA256.hexdigest('blocked-body'),
|
||||
reason: 'copyright_high_risk',
|
||||
created_by_user: user)
|
||||
blob_count = ActiveStorage::Blob.count
|
||||
|
||||
expect do
|
||||
result =
|
||||
described_class.import!(
|
||||
source_kind: 'google_drive_file',
|
||||
source_path: '素材/blocked.png',
|
||||
source_file_id: 'drive-file-blocked',
|
||||
filename: 'blocked.png',
|
||||
content_type: 'image/png',
|
||||
file_downloader: -> { tempfile_for('blocked-body') },
|
||||
export_path: '素材/blocked.png',
|
||||
updated_by_user: user)
|
||||
|
||||
expect(result.action).to eq(:suppressed)
|
||||
expect(result.suppression.reason).to eq('copyright_high_risk')
|
||||
end.not_to change(Material, :count)
|
||||
|
||||
expect(ActiveStorage::Blob.count).to eq(blob_count)
|
||||
expect(Material.find_by(source_file_id: 'drive-file-blocked')).to be_nil
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,89 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe MaterialSyncRunner do
|
||||
let(:user) { create(:user, :member) }
|
||||
let(:source) do
|
||||
MaterialSyncSource.create!(
|
||||
name: 'Drive source',
|
||||
source_kind: 'google_drive_path',
|
||||
source_file_id: 'folder-123',
|
||||
profile: 'legacy_drive',
|
||||
created_by_user: user,
|
||||
updated_by_user: user)
|
||||
end
|
||||
let(:drive_client) { instance_double(GoogleDrive::ApiClient) }
|
||||
|
||||
describe '#sync!' do
|
||||
it 'imports google drive path candidates as they are yielded' do
|
||||
first_entry = { id: 'file-1',
|
||||
name: 'a.png',
|
||||
mime_type: 'image/png',
|
||||
relative_path: '素材/a.png',
|
||||
sha256_checksum: 'sha-a',
|
||||
web_view_link: 'https://drive.google.com/file/d/file-1/view',
|
||||
web_content_link: nil }
|
||||
second_entry = { id: 'file-2',
|
||||
name: 'b.png',
|
||||
mime_type: 'image/png',
|
||||
relative_path: '素材/b.png',
|
||||
sha256_checksum: 'sha-b',
|
||||
web_view_link: 'https://drive.google.com/file/d/file-2/view',
|
||||
web_content_link: nil }
|
||||
yielded = []
|
||||
|
||||
allow(GoogleDrive::ApiClient).to receive(:new).and_return(drive_client)
|
||||
allow(drive_client).to receive(:each_material_file_under_folder) do |folder_id, &block|
|
||||
expect(folder_id).to eq('folder-123')
|
||||
block.call(first_entry)
|
||||
expect(yielded).to eq(['素材/a.png'])
|
||||
block.call(second_entry)
|
||||
end
|
||||
allow(MaterialSyncImporter).to receive(:import!) do |candidate|
|
||||
yielded << candidate.fetch(:source_path)
|
||||
instance_double(MaterialSyncImporter::Result,
|
||||
action: yielded.last == '素材/a.png' ? :imported : :updated)
|
||||
end
|
||||
|
||||
result = described_class.new(source).sync!
|
||||
|
||||
expect(yielded).to eq(['素材/a.png', '素材/b.png'])
|
||||
expect(result.imported).to eq(1)
|
||||
expect(result.updated).to eq(1)
|
||||
expect(result.unchanged).to eq(0)
|
||||
expect(source.reload.last_synced_at).to be_present
|
||||
end
|
||||
|
||||
it 'logs google drive path progress at first item and summary' do
|
||||
entry = { id: 'file-1',
|
||||
name: 'a.png',
|
||||
mime_type: 'image/png',
|
||||
relative_path: '素材/a.png',
|
||||
sha256_checksum: 'sha-a',
|
||||
web_view_link: 'https://drive.google.com/file/d/file-1/view',
|
||||
web_content_link: nil }
|
||||
logged = []
|
||||
|
||||
allow(GoogleDrive::ApiClient).to receive(:new).and_return(drive_client)
|
||||
allow(drive_client).to receive(:each_material_file_under_folder) do |_folder_id, &block|
|
||||
block.call(entry)
|
||||
end
|
||||
allow(MaterialSyncImporter).to receive(:import!)
|
||||
.and_return(instance_double(MaterialSyncImporter::Result, action: :imported))
|
||||
allow(Rails.logger).to receive(:info) { |message| logged << JSON.parse(message) }
|
||||
|
||||
described_class.new(source).sync!
|
||||
|
||||
progress_logs = logged.select { |row| row['folder_id'] == 'folder-123' }
|
||||
expect(progress_logs.map { |row| row['progress'] }).to eq(['scan', 'summary'])
|
||||
expect(progress_logs.last).to include(
|
||||
'material_sync_source_id' => source.id,
|
||||
'material_sync_source_name' => 'Drive source',
|
||||
'scanned_count' => 1,
|
||||
'imported' => 1,
|
||||
'updated' => 0,
|
||||
'unchanged' => 0,
|
||||
'suppressed' => 0,
|
||||
'failed' => 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
新しい課題から参照
ユーザをブロックする