776dea87d9
開発環境では、**DB を壊さない前提で、migration → API → 画面 → 同期 → ZIP → 履歴**の順に見るのがよいです。今回の差分は素材管理全体に触っているので、単体でチョンチョン見るより、素材の一生を通すのが早いです。
## 0. 先に方針
**やらないこと:**
```sh id="snb3i6"
rails db:drop
rails db:reset
rails db:setup
DISABLE_DATABASE_ENVIRONMENT_CHECK=1 ...
```
これは禁止。
開発 DB に本番データを入れているなら、床板を剥がして耐震確認するようなものです。
---
## 1. migration 確認
まず現在の状態を見る。
```sh id="qsdfpt"
cd backend
RAILS_ENV=development bundle exec rails db:migrate:status
```
その後、通常 migration。
```sh id="a89fir"
RAILS_ENV=development bundle exec rails db:migrate
```
見るポイント:
```txt id="c1u57a"
materials に source_* / normalized_source_key / version_no がある
material_versions に event_type / file snapshot / source snapshot がある
material_export_items がある
material_sync_suppressions がある
material_sync_sources がある
既存 materials に material_versions version_no=1 create が backfill されている
```
確認用:
```sh id="g4vd4m"
RAILS_ENV=development bundle exec rails runner '
puts "materials=#{Material.count}"
puts "versions=#{MaterialVersion.count}"
puts "materials without versions=#{Material.left_joins(:material_versions).where(material_versions: { id: nil }).count}"
puts "sync suppressions table=#{ActiveRecord::Base.connection.table_exists?(:material_sync_suppressions)}"
'
```
ここで `materials without versions=0` になれば、backfill は通っています。
---
## 2. 既存素材一覧の画面確認
フロントを起動して `/materials` を見る。
```sh id="vl28jd"
cd frontend
npm run dev
```
見る観点:
```txt id="i2ovxw"
初期表示で素材が出る
初期表示ではグルーピングがオフ
タグなし素材も出る
カード表示でサムネまたは代替テキストが出る
一覧表示に切り替えられる
q / tag_state / media_kind / sort / direction が効く
```
ここでまず、普通の素材一覧が壊れていないことを確認します。
---
## 3. 左タグバーの確認
`/materials` を開いて左タグバーからタグを選ぶ。
見る観点:
```txt id="n6udul"
URL が tag_id=...&include_descendants=1&group_by=parent_tag になる
選択中タグが左バーで強調される
一覧上部に「選択中」の表示が出る
「タグ選択を解除」で通常表示に戻れる
解除後、tag_id / include_descendants / group_by / page が消える
子タグ・孫タグの素材も一覧に出る
親タググルーピングされる
```
特に重要なのはこれ。
```txt id="d33os0"
親タグ A を選択
A に直接紐づく素材
A > B に紐づく素材
A > B > C に紐づく素材
が同じ一覧に出ること
```
---
## 4. 選択タグから素材追加
左タグからタグを選択した状態で、一覧上部の **このタグに素材を追加** を押す。
見る観点:
```txt id="qdd5uw"
素材追加画面の tag 欄に選択中タグ名が初期入力されている
file または url を指定して保存できる
保存後 return_to で元のタグ選択済み一覧に戻る
戻った一覧に追加した素材が出る
material_versions に create が 1 件できる
```
Rails console でも確認できます。
```sh id="i07rrb"
RAILS_ENV=development bundle exec rails runner '
m = Material.order(id: :desc).first
puts({ id: m.id, tag: m.tag&.name, versions: m.material_versions.count, version_no: m.version_no }.inspect)
'
```
---
## 5. グループ見出しから素材追加
親タググルーピング表示中に、各グループ見出しの **このタグに素材を追加** を押す。
見る観点:
```txt id="fpx8w4"
グループタグ名が tag 欄に初期入力される
保存後、元の一覧に戻る
追加素材がそのグループ内に出る
```
ここは今回の導線の肝です。棚の見出しから直接その棚へ素材を置けるかを見る。
---
## 6. 素材更新と履歴
既存素材の詳細または編集導線から、タグ・URL・ファイル・export path を更新する。
見る観点:
```txt id="w36i61"
更新前 snapshot が無ければ create が補われる
更新後 update version ができる
file_blob_id / file_filename / file_sha256 が material_versions に入る
export_paths_json が履歴に残る
/materials/changes または /materials/versions で履歴が見える
```
console:
```sh id="a3okhv"
RAILS_ENV=development bundle exec rails runner '
m = Material.order(updated_at: :desc).first
puts m.material_versions.order(:version_no).map { |v|
[v.version_no, v.event_type, v.tag_name, v.file_filename, v.file_sha256, v.export_paths_hash]
}.inspect
'
```
---
## 7. サムネイル
画像素材を追加して、一覧にサムネイルが出るか確認。
動画素材があるなら、`ffmpeg` が入っている環境で backfill。
```sh id="qxm8fj"
cd backend
RAILS_ENV=development bundle exec rails materials:thumbnails:backfill
```
見る観点:
```txt id="s9a1vf"
画像は 180x180 のサムネが付く
動画はフレームからサムネが作られる
非対応ファイルは代替テキスト表示になる
ログに result が出る
```
---
## 8. ZIP export
export path がある素材を用意して、ブラウザで確認。
```txt id="aq7f7o"
/materials/download.zip?profile=legacy_drive
```
見る観点:
```txt id="h4rve5"
ZIP が落ちる
entry path が material_export_items.export_path になる
disabled な export item は入らない
ファイル実体が欠けている場合は 422 と missing_files が返る
```
---
## 9. 抑止
`/materials/suppressions` で path prefix 抑止を追加する。
見る観点:
```txt id="w80jbu"
member で作成できる
guest は forbidden / unauthorized
google_drive_path_prefix で既存素材が discard される
discard 履歴が material_versions に残る
同期時に同じ source_path 配下が再作成されない
```
---
## 10. Google Drive 同期
開発環境では、まず小さいフォルダでやるのがよいです。
いきなり本番素材集フォルダを食わせると、ログが藪になります 🌿
必要な ENV:
```sh id="kzbb7f"
GOOGLE_DRIVE_SERVICE_ACCOUNT_EMAIL=...
GOOGLE_DRIVE_PRIVATE_KEY_PATH=...
MATERIAL_SYNC_SOURCE_KIND=google_drive_path
MATERIAL_SYNC_SOURCE_FILE_ID=<folder_id>
MATERIAL_SYNC_SOURCE_NAME=dev-small-folder
MATERIAL_SYNC_SOURCE_PROFILE=legacy_drive
```
seed で source 作成、または console で作成。
```sh id="sec40c"
RAILS_ENV=development bundle exec rails db:seed
RAILS_ENV=development bundle exec rails materials:sync
```
見る観点:
```txt id="a5ltqu"
imported / updated / unchanged / suppressed / failed がログに出る
2 回目実行で unchanged が増える
tag は nil のままでも保存できる
人手で tag / url を付けた既存同期素材が、再同期で消えない
Google native file は skip される
download 後 sha256 block が効く
```
---
## 11. schema.rb は別途確認
これはテストというより merge gate です。
今回まだ怪しいので、差分に以下が混ざっていないことを確認します。
```txt id="s3k6da"
wiki_assets 削除
wiki_pages.next_asset_no 削除
素材管理と無関係な CHECK constraint 削除
素材管理と無関係な index order 消失
```
ここが残るなら、機能テストが通っても merge は止めた方がいいです。
---
## 最小テスト順
時間がないなら、この順で十分です。
```txt id="c1k2pw"
1. db:migrate
2. materials without versions = 0 を確認
3. /materials 初期表示
4. 左タグ選択 → 子孫込み表示 → グルーピング
5. タグ選択解除
6. 選択タグから素材追加 → return_to で戻る
7. グループ見出しから素材追加
8. 更新して material_versions を確認
9. ZIP export
10. 小さい Drive folder で materials:sync を 2 回
```
これで、今回の差分の主要な導線はほぼ踏めます。
Reviewed-on: #381
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
732 行
26 KiB
Ruby
732 行
26 KiB
Ruby
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
|
|
|
|
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) 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
|