コミットを比較
3 コミット
ce28661271
...
41a98ff725
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 41a98ff725 | |||
| c836369dfc | |||
| 363146c219 |
@@ -523,10 +523,7 @@ class MaterialsController < ApplicationController
|
||||
content_type: file.content_type,
|
||||
)
|
||||
|
||||
if file_sha256.present?
|
||||
blob.metadata['sha256'] = file_sha256
|
||||
blob.save! if blob.changed?
|
||||
end
|
||||
MaterialFileSha256.assign_metadata_sha256!(blob, file_sha256)
|
||||
|
||||
blob
|
||||
ensure
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
class Material < ApplicationRecord
|
||||
include MyDiscard
|
||||
|
||||
SOURCE_KINDS = ['uri', 'google_drive_path', 'google_drive_file',
|
||||
'legacy_drive_path'].freeze
|
||||
|
||||
default_scope -> { kept }
|
||||
|
||||
belongs_to :parent, class_name: 'Material', optional: true
|
||||
@@ -20,7 +23,7 @@ class Material < ApplicationRecord
|
||||
|
||||
validates :tag_id, uniqueness: true, allow_nil: true
|
||||
validates :source_kind,
|
||||
inclusion: { in: MaterialSyncSuppression::SOURCE_KINDS },
|
||||
inclusion: { in: SOURCE_KINDS },
|
||||
allow_blank: true
|
||||
|
||||
validate :file_must_be_attached
|
||||
|
||||
@@ -1,16 +1,39 @@
|
||||
require 'digest'
|
||||
require 'json'
|
||||
|
||||
class MaterialFileSha256
|
||||
def self.blob_metadata blob
|
||||
metadata = blob.metadata
|
||||
return metadata if metadata.is_a?(Hash)
|
||||
return { } if metadata.blank?
|
||||
|
||||
JSON.parse(metadata)
|
||||
rescue JSON::ParserError
|
||||
{ }
|
||||
end
|
||||
|
||||
def self.metadata_sha256 blob
|
||||
blob_metadata(blob)['sha256'].presence
|
||||
end
|
||||
|
||||
def self.assign_metadata_sha256! blob, sha256
|
||||
return if sha256.blank?
|
||||
|
||||
metadata = blob_metadata(blob)
|
||||
metadata['sha256'] = sha256
|
||||
blob.metadata = metadata
|
||||
blob.save! if blob.changed?
|
||||
end
|
||||
|
||||
def self.from_blob blob, allow_download: false
|
||||
sha256 = blob.metadata['sha256']
|
||||
sha256 = metadata_sha256(blob)
|
||||
return sha256 if sha256.present?
|
||||
return nil unless allow_download
|
||||
|
||||
begin
|
||||
blob.open do |file|
|
||||
sha256 = Digest::SHA256.file(file.path).hexdigest
|
||||
blob.metadata['sha256'] = sha256
|
||||
blob.save! if blob.changed?
|
||||
assign_metadata_sha256!(blob, sha256)
|
||||
sha256
|
||||
end
|
||||
rescue ActiveStorage::FileNotFoundError, ArgumentError => error
|
||||
|
||||
@@ -148,8 +148,7 @@ class MaterialSyncImporter
|
||||
io: tempfile,
|
||||
filename: @attributes[:filename],
|
||||
content_type: @attributes[:content_type])
|
||||
blob.metadata['sha256'] = file_sha256 if file_sha256.present?
|
||||
blob.save! if blob.changed?
|
||||
MaterialFileSha256.assign_metadata_sha256!(blob, file_sha256)
|
||||
tempfile.rewind
|
||||
blob
|
||||
end
|
||||
@@ -226,7 +225,7 @@ class MaterialSyncImporter
|
||||
if expected_sha256.present?
|
||||
return false unless material.file.attached?
|
||||
|
||||
return material.file.blob.metadata['sha256'] == expected_sha256
|
||||
return MaterialFileSha256.metadata_sha256(material.file.blob) == expected_sha256
|
||||
end
|
||||
|
||||
!material.file.attached?
|
||||
|
||||
@@ -57,7 +57,7 @@ class MaterialVersionRecorder < VersionRecorder
|
||||
file_content_type: blob.content_type,
|
||||
file_byte_size: blob.byte_size,
|
||||
file_checksum: blob.checksum,
|
||||
file_sha256: blob.metadata['sha256'] }
|
||||
file_sha256: MaterialFileSha256.metadata_sha256(blob) }
|
||||
end
|
||||
|
||||
def empty_file_snapshot
|
||||
|
||||
生成ファイル
+35
-8
@@ -72,6 +72,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
|
||||
t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id"
|
||||
t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id"
|
||||
t.index ["user_id"], name: "index_gekanator_games_on_user_id"
|
||||
t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
|
||||
end
|
||||
|
||||
create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
@@ -274,9 +275,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "created_by_user_id"
|
||||
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
|
||||
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at"
|
||||
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at"
|
||||
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
|
||||
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
|
||||
t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true
|
||||
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
|
||||
end
|
||||
|
||||
create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
@@ -285,13 +287,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id"
|
||||
t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self"
|
||||
end
|
||||
|
||||
create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "post_id", null: false
|
||||
t.bigint "target_post_id", null: false
|
||||
t.float "cos", null: false
|
||||
t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos"
|
||||
t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos", order: { cos: :desc }
|
||||
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
|
||||
end
|
||||
|
||||
@@ -331,6 +334,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
|
||||
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
|
||||
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
|
||||
t.index ["post_id"], name: "index_post_versions_on_post_id"
|
||||
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
|
||||
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
|
||||
end
|
||||
|
||||
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
@@ -345,6 +350,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
|
||||
t.integer "version_no", null: false
|
||||
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
|
||||
t.index ["url"], name: "index_posts_on_url", unique: true
|
||||
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
|
||||
end
|
||||
|
||||
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
@@ -391,7 +397,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
|
||||
t.bigint "tag_id", null: false
|
||||
t.bigint "target_tag_id", null: false
|
||||
t.float "cos", null: false
|
||||
t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos"
|
||||
t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos", order: { cos: :desc }
|
||||
t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id"
|
||||
end
|
||||
|
||||
@@ -401,30 +407,32 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
|
||||
t.string "event_type", null: false
|
||||
t.string "name", null: false
|
||||
t.string "category", null: false
|
||||
t.datetime "deprecated_at"
|
||||
t.text "aliases", null: false
|
||||
t.text "parent_tag_ids", null: false
|
||||
t.datetime "deprecated_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "created_by_user_id"
|
||||
t.index ["created_at"], name: "index_tag_versions_on_created_at"
|
||||
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at"
|
||||
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at"
|
||||
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
|
||||
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
|
||||
t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true
|
||||
t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive"
|
||||
end
|
||||
|
||||
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_name_id", null: false
|
||||
t.string "category", default: "general", null: false
|
||||
t.datetime "deprecated_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "post_count", default: 0, null: false
|
||||
t.datetime "deprecated_at"
|
||||
t.datetime "discarded_at"
|
||||
t.integer "version_no", null: false
|
||||
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
|
||||
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
|
||||
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
|
||||
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
|
||||
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
|
||||
end
|
||||
|
||||
create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
@@ -549,6 +557,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
|
||||
t.index ["banned_at"], name: "index_users_on_banned_at"
|
||||
end
|
||||
|
||||
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "wiki_page_id", null: false
|
||||
t.integer "no", null: false
|
||||
t.string "alt_text"
|
||||
t.binary "sha256", limit: 32, null: false
|
||||
t.bigint "created_by_user_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
|
||||
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
|
||||
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
|
||||
end
|
||||
|
||||
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "sha256", limit: 64, null: false
|
||||
t.text "body", null: false
|
||||
@@ -565,11 +586,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.datetime "discarded_at"
|
||||
t.integer "next_asset_no", default: 1, null: false
|
||||
t.integer "version_no", null: false
|
||||
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
|
||||
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
|
||||
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
|
||||
t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id"
|
||||
t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive"
|
||||
end
|
||||
|
||||
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
@@ -614,6 +637,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
|
||||
t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id"
|
||||
t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true
|
||||
t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id"
|
||||
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid"
|
||||
t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive"
|
||||
end
|
||||
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
@@ -692,6 +717,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
|
||||
add_foreign_key "user_ips", "users"
|
||||
add_foreign_key "user_post_views", "posts"
|
||||
add_foreign_key "user_post_views", "users"
|
||||
add_foreign_key "wiki_assets", "users", column: "created_by_user_id"
|
||||
add_foreign_key "wiki_assets", "wiki_pages"
|
||||
add_foreign_key "wiki_pages", "tag_names"
|
||||
add_foreign_key "wiki_pages", "users", column: "created_user_id"
|
||||
add_foreign_key "wiki_pages", "users", column: "updated_user_id"
|
||||
|
||||
@@ -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
|
||||
@@ -113,6 +113,68 @@ 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
|
||||
|
||||
@@ -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
|
||||
@@ -126,4 +126,70 @@ describe ('MaterialListPage', () => {
|
||||
expect (screen.getByText ('サイズ:')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('2.0 KB')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('shows the selected tag scope and tag-specific add links', async () => {
|
||||
api.apiGet.mockResolvedValueOnce ({
|
||||
materials: [
|
||||
buildMaterial ({
|
||||
id: 10,
|
||||
tag: buildTag ({ id: 30, name: '泣き', category: 'material' }),
|
||||
}),
|
||||
],
|
||||
count: 1,
|
||||
tagScope: {
|
||||
tag: { id: 20, name: '伊地知ニジカ', category: 'material' },
|
||||
includeDescendants: true,
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
key: 'tag:30',
|
||||
tag: { id: 30, name: '泣き', category: 'material' },
|
||||
materialIds: [10],
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
renderWithProviders (
|
||||
<MaterialListPage/>,
|
||||
{ route: '/materials?tag_id=20&include_descendants=1&group_by=parent_tag' },
|
||||
)
|
||||
|
||||
expect (await screen.findByText (
|
||||
(_, element) => element?.textContent === '伊地知ニジカ 配下の素材を表示中',
|
||||
)).toBeInTheDocument ()
|
||||
|
||||
const addLinks = screen.getAllByRole ('link', { name: 'このタグに素材を追加' })
|
||||
expect (addLinks[0]).toHaveAttribute (
|
||||
'href',
|
||||
'/materials/new?tag=%E4%BC%8A%E5%9C%B0%E7%9F%A5%E3%83%8B%E3%82%B8%E3%82%AB'
|
||||
+ '&return_to=%2Fmaterials%3Ftag_id%3D20%26include_descendants%3D1%26group_by%3Dparent_tag',
|
||||
)
|
||||
expect (addLinks[1]).toHaveAttribute (
|
||||
'href',
|
||||
'/materials/new?tag=%E6%B3%A3%E3%81%8D'
|
||||
+ '&return_to=%2Fmaterials%3Ftag_id%3D20%26include_descendants%3D1%26group_by%3Dparent_tag',
|
||||
)
|
||||
expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute (
|
||||
'href',
|
||||
'/materials?material_filter=present',
|
||||
)
|
||||
})
|
||||
|
||||
it ('shows a clear link when tag_id does not resolve to tag_scope', async () => {
|
||||
api.apiGet.mockResolvedValueOnce ({
|
||||
materials: [],
|
||||
count: 0,
|
||||
tagScope: null,
|
||||
groups: [],
|
||||
})
|
||||
|
||||
renderWithProviders (<MaterialListPage/>, { route: '/materials?tag_id=999' })
|
||||
|
||||
expect (await screen.findByText ('選択中タグが見つかりません.')).toBeInTheDocument ()
|
||||
expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute (
|
||||
'href',
|
||||
'/materials?material_filter=present',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -106,7 +106,7 @@ const clearedTagSelectionPath = (
|
||||
const qs = new URLSearchParams (locationSearch)
|
||||
qs.delete ('tag_id')
|
||||
qs.delete ('include_descendants')
|
||||
qs.set ('group_by', 'none')
|
||||
qs.delete ('group_by')
|
||||
qs.delete ('page')
|
||||
qs.set ('material_filter', materialFilter)
|
||||
return `/materials?${ qs.toString () }`
|
||||
@@ -471,6 +471,18 @@ const MaterialListPage: FC = () => {
|
||||
タグ選択を解除
|
||||
</PrefetchLink>
|
||||
</div>)}
|
||||
{(tagId != null && tagScope == null) && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg border
|
||||
border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900
|
||||
dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
|
||||
<span>選択中タグが見つかりません.</span>
|
||||
<PrefetchLink
|
||||
to={clearedTagSelectionPath (location.search, materialFilter)}
|
||||
className="font-medium underline underline-offset-2
|
||||
text-amber-800 dark:text-amber-200">
|
||||
タグ選択を解除
|
||||
</PrefetchLink>
|
||||
</div>)}
|
||||
|
||||
{tagState === 'untagged' && (
|
||||
<PrefetchLink
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
|
||||
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
@@ -17,6 +18,11 @@ const toastApi = vi.hoisted (() => ({
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
|
||||
const LocationView = () => {
|
||||
const location = useLocation ()
|
||||
return <div>{location.pathname + location.search}</div>
|
||||
}
|
||||
|
||||
describe ('MaterialNewPage', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
@@ -69,4 +75,50 @@ describe ('MaterialNewPage', () => {
|
||||
expect (await screen.findAllByText ('ファイルまたは URL は必須です.')).toHaveLength (2)
|
||||
expect (screen.getAllByRole ('textbox')[1]).toHaveAttribute ('aria-invalid', 'true')
|
||||
})
|
||||
|
||||
it ('returns only to safe material URLs after successful creation', async () => {
|
||||
api.apiPost.mockResolvedValueOnce ({})
|
||||
|
||||
renderWithProviders (
|
||||
<Routes>
|
||||
<Route path="/materials/new" element={<MaterialNewPage/>}/>
|
||||
<Route path="/materials" element={<LocationView/>}/>
|
||||
</Routes>,
|
||||
{
|
||||
route:
|
||||
'/materials/new?tag=%E8%99%B9%E5%A4%8F'
|
||||
+ '&return_to=%2Fmaterials%3Ftag_id%3D20',
|
||||
},
|
||||
)
|
||||
|
||||
fireEvent.change (screen.getAllByRole ('textbox')[1], {
|
||||
target: { value: 'https://example.com/ref' },
|
||||
})
|
||||
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||
|
||||
expect (await screen.findByText ('/materials?tag_id=20')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('ignores unsafe return_to values after successful creation', async () => {
|
||||
api.apiPost.mockResolvedValueOnce ({})
|
||||
|
||||
renderWithProviders (
|
||||
<Routes>
|
||||
<Route path="/materials/new" element={<MaterialNewPage/>}/>
|
||||
<Route path="/materials" element={<LocationView/>}/>
|
||||
</Routes>,
|
||||
{
|
||||
route:
|
||||
'/materials/new?tag=%E8%99%B9%E5%A4%8F'
|
||||
+ '&return_to=https%3A%2F%2Fexample.com%2Fevil',
|
||||
},
|
||||
)
|
||||
|
||||
fireEvent.change (screen.getAllByRole ('textbox')[1], {
|
||||
target: { value: 'https://example.com/ref' },
|
||||
})
|
||||
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||
|
||||
expect (await screen.findByText ('/materials')).toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ const MaterialNewPage: FC = () => {
|
||||
const query = new URLSearchParams (location.search)
|
||||
const tagQuery = query.get ('tag') ?? ''
|
||||
const returnToQuery = query.get ('return_to') ?? ''
|
||||
const safeReturnTo = returnToQuery.startsWith ('/materials') ? returnToQuery : ''
|
||||
|
||||
const navigate = useNavigate ()
|
||||
|
||||
@@ -55,7 +56,7 @@ const MaterialNewPage: FC = () => {
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
|
||||
toast ({ title: '送信成功!' })
|
||||
navigate (returnToQuery || `/materials?tag=${ encodeURIComponent (tag) }`)
|
||||
navigate (safeReturnTo || '/materials')
|
||||
},
|
||||
onError: error => {
|
||||
applyValidationError (error)
|
||||
|
||||
新しい課題から参照
ユーザをブロックする