コミットを比較
3 コミット
ce28661271
...
41a98ff725
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 41a98ff725 | |||
| c836369dfc | |||
| 363146c219 |
@@ -523,10 +523,7 @@ class MaterialsController < ApplicationController
|
|||||||
content_type: file.content_type,
|
content_type: file.content_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
if file_sha256.present?
|
MaterialFileSha256.assign_metadata_sha256!(blob, file_sha256)
|
||||||
blob.metadata['sha256'] = file_sha256
|
|
||||||
blob.save! if blob.changed?
|
|
||||||
end
|
|
||||||
|
|
||||||
blob
|
blob
|
||||||
ensure
|
ensure
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
class Material < ApplicationRecord
|
class Material < ApplicationRecord
|
||||||
include MyDiscard
|
include MyDiscard
|
||||||
|
|
||||||
|
SOURCE_KINDS = ['uri', 'google_drive_path', 'google_drive_file',
|
||||||
|
'legacy_drive_path'].freeze
|
||||||
|
|
||||||
default_scope -> { kept }
|
default_scope -> { kept }
|
||||||
|
|
||||||
belongs_to :parent, class_name: 'Material', optional: true
|
belongs_to :parent, class_name: 'Material', optional: true
|
||||||
@@ -20,7 +23,7 @@ class Material < ApplicationRecord
|
|||||||
|
|
||||||
validates :tag_id, uniqueness: true, allow_nil: true
|
validates :tag_id, uniqueness: true, allow_nil: true
|
||||||
validates :source_kind,
|
validates :source_kind,
|
||||||
inclusion: { in: MaterialSyncSuppression::SOURCE_KINDS },
|
inclusion: { in: SOURCE_KINDS },
|
||||||
allow_blank: true
|
allow_blank: true
|
||||||
|
|
||||||
validate :file_must_be_attached
|
validate :file_must_be_attached
|
||||||
|
|||||||
@@ -1,16 +1,39 @@
|
|||||||
require 'digest'
|
require 'digest'
|
||||||
|
require 'json'
|
||||||
|
|
||||||
class MaterialFileSha256
|
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
|
def self.from_blob blob, allow_download: false
|
||||||
sha256 = blob.metadata['sha256']
|
sha256 = metadata_sha256(blob)
|
||||||
return sha256 if sha256.present?
|
return sha256 if sha256.present?
|
||||||
return nil unless allow_download
|
return nil unless allow_download
|
||||||
|
|
||||||
begin
|
begin
|
||||||
blob.open do |file|
|
blob.open do |file|
|
||||||
sha256 = Digest::SHA256.file(file.path).hexdigest
|
sha256 = Digest::SHA256.file(file.path).hexdigest
|
||||||
blob.metadata['sha256'] = sha256
|
assign_metadata_sha256!(blob, sha256)
|
||||||
blob.save! if blob.changed?
|
|
||||||
sha256
|
sha256
|
||||||
end
|
end
|
||||||
rescue ActiveStorage::FileNotFoundError, ArgumentError => error
|
rescue ActiveStorage::FileNotFoundError, ArgumentError => error
|
||||||
|
|||||||
@@ -148,8 +148,7 @@ class MaterialSyncImporter
|
|||||||
io: tempfile,
|
io: tempfile,
|
||||||
filename: @attributes[:filename],
|
filename: @attributes[:filename],
|
||||||
content_type: @attributes[:content_type])
|
content_type: @attributes[:content_type])
|
||||||
blob.metadata['sha256'] = file_sha256 if file_sha256.present?
|
MaterialFileSha256.assign_metadata_sha256!(blob, file_sha256)
|
||||||
blob.save! if blob.changed?
|
|
||||||
tempfile.rewind
|
tempfile.rewind
|
||||||
blob
|
blob
|
||||||
end
|
end
|
||||||
@@ -226,7 +225,7 @@ class MaterialSyncImporter
|
|||||||
if expected_sha256.present?
|
if expected_sha256.present?
|
||||||
return false unless material.file.attached?
|
return false unless material.file.attached?
|
||||||
|
|
||||||
return material.file.blob.metadata['sha256'] == expected_sha256
|
return MaterialFileSha256.metadata_sha256(material.file.blob) == expected_sha256
|
||||||
end
|
end
|
||||||
|
|
||||||
!material.file.attached?
|
!material.file.attached?
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class MaterialVersionRecorder < VersionRecorder
|
|||||||
file_content_type: blob.content_type,
|
file_content_type: blob.content_type,
|
||||||
file_byte_size: blob.byte_size,
|
file_byte_size: blob.byte_size,
|
||||||
file_checksum: blob.checksum,
|
file_checksum: blob.checksum,
|
||||||
file_sha256: blob.metadata['sha256'] }
|
file_sha256: MaterialFileSha256.metadata_sha256(blob) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def empty_file_snapshot
|
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 ["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 ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id"
|
||||||
t.index ["user_id"], name: "index_gekanator_games_on_user_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
|
end
|
||||||
|
|
||||||
create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
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.datetime "created_at", null: false
|
||||||
t.bigint "created_by_user_id"
|
t.bigint "created_by_user_id"
|
||||||
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
|
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 ["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"
|
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.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
|
end
|
||||||
|
|
||||||
create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
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 "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id"
|
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
|
end
|
||||||
|
|
||||||
create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
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 "post_id", null: false
|
||||||
t.bigint "target_post_id", null: false
|
t.bigint "target_post_id", null: false
|
||||||
t.float "cos", 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"
|
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
|
||||||
end
|
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 ["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", "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.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
|
end
|
||||||
|
|
||||||
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
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.integer "version_no", null: false
|
||||||
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
|
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
|
||||||
t.index ["url"], name: "index_posts_on_url", unique: true
|
t.index ["url"], name: "index_posts_on_url", unique: true
|
||||||
|
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
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 "tag_id", null: false
|
||||||
t.bigint "target_tag_id", null: false
|
t.bigint "target_tag_id", null: false
|
||||||
t.float "cos", 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"
|
t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -401,30 +407,32 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
|
|||||||
t.string "event_type", null: false
|
t.string "event_type", null: false
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "category", null: false
|
t.string "category", null: false
|
||||||
|
t.datetime "deprecated_at"
|
||||||
t.text "aliases", null: false
|
t.text "aliases", null: false
|
||||||
t.text "parent_tag_ids", null: false
|
t.text "parent_tag_ids", null: false
|
||||||
t.datetime "deprecated_at"
|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.bigint "created_by_user_id"
|
t.bigint "created_by_user_id"
|
||||||
t.index ["created_at"], name: "index_tag_versions_on_created_at"
|
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 ["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"
|
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.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
|
end
|
||||||
|
|
||||||
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.bigint "tag_name_id", null: false
|
t.bigint "tag_name_id", null: false
|
||||||
t.string "category", default: "general", null: false
|
t.string "category", default: "general", null: false
|
||||||
t.datetime "deprecated_at"
|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "post_count", default: 0, null: false
|
t.integer "post_count", default: 0, null: false
|
||||||
|
t.datetime "deprecated_at"
|
||||||
t.datetime "discarded_at"
|
t.datetime "discarded_at"
|
||||||
t.integer "version_no", null: false
|
t.integer "version_no", null: false
|
||||||
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
|
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
|
||||||
t.index ["discarded_at"], name: "index_tags_on_discarded_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.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 "(`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
|
end
|
||||||
|
|
||||||
create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
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"
|
t.index ["banned_at"], name: "index_users_on_banned_at"
|
||||||
end
|
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|
|
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "sha256", limit: 64, null: false
|
t.string "sha256", limit: 64, null: false
|
||||||
t.text "body", 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 "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.datetime "discarded_at"
|
t.datetime "discarded_at"
|
||||||
|
t.integer "next_asset_no", default: 1, null: false
|
||||||
t.integer "version_no", null: false
|
t.integer "version_no", null: false
|
||||||
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
|
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 ["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 ["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.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
|
end
|
||||||
|
|
||||||
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
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 ["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", "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.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
|
end
|
||||||
|
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
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_ips", "users"
|
||||||
add_foreign_key "user_post_views", "posts"
|
add_foreign_key "user_post_views", "posts"
|
||||||
add_foreign_key "user_post_views", "users"
|
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", "tag_names"
|
||||||
add_foreign_key "wiki_pages", "users", column: "created_user_id"
|
add_foreign_key "wiki_pages", "users", column: "created_user_id"
|
||||||
add_foreign_key "wiki_pages", "users", column: "updated_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.size).to eq(1)
|
||||||
expect(response_materials.first['id']).to eq(material_b.id)
|
expect(response_materials.first['id']).to eq(material_b.id)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe 'GET /materials/:id' do
|
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 ('サイズ:')).toBeInTheDocument ()
|
||||||
expect (screen.getByText ('2.0 KB')).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)
|
const qs = new URLSearchParams (locationSearch)
|
||||||
qs.delete ('tag_id')
|
qs.delete ('tag_id')
|
||||||
qs.delete ('include_descendants')
|
qs.delete ('include_descendants')
|
||||||
qs.set ('group_by', 'none')
|
qs.delete ('group_by')
|
||||||
qs.delete ('page')
|
qs.delete ('page')
|
||||||
qs.set ('material_filter', materialFilter)
|
qs.set ('material_filter', materialFilter)
|
||||||
return `/materials?${ qs.toString () }`
|
return `/materials?${ qs.toString () }`
|
||||||
@@ -471,6 +471,18 @@ const MaterialListPage: FC = () => {
|
|||||||
タグ選択を解除
|
タグ選択を解除
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</div>)}
|
</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' && (
|
{tagState === 'untagged' && (
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
||||||
import { renderWithProviders } from '@/test/render'
|
import { renderWithProviders } from '@/test/render'
|
||||||
@@ -17,6 +18,11 @@ const toastApi = vi.hoisted (() => ({
|
|||||||
vi.mock ('@/lib/api', () => api)
|
vi.mock ('@/lib/api', () => api)
|
||||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||||
|
|
||||||
|
const LocationView = () => {
|
||||||
|
const location = useLocation ()
|
||||||
|
return <div>{location.pathname + location.search}</div>
|
||||||
|
}
|
||||||
|
|
||||||
describe ('MaterialNewPage', () => {
|
describe ('MaterialNewPage', () => {
|
||||||
beforeEach (() => {
|
beforeEach (() => {
|
||||||
vi.clearAllMocks ()
|
vi.clearAllMocks ()
|
||||||
@@ -69,4 +75,50 @@ describe ('MaterialNewPage', () => {
|
|||||||
expect (await screen.findAllByText ('ファイルまたは URL は必須です.')).toHaveLength (2)
|
expect (await screen.findAllByText ('ファイルまたは URL は必須です.')).toHaveLength (2)
|
||||||
expect (screen.getAllByRole ('textbox')[1]).toHaveAttribute ('aria-invalid', 'true')
|
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 query = new URLSearchParams (location.search)
|
||||||
const tagQuery = query.get ('tag') ?? ''
|
const tagQuery = query.get ('tag') ?? ''
|
||||||
const returnToQuery = query.get ('return_to') ?? ''
|
const returnToQuery = query.get ('return_to') ?? ''
|
||||||
|
const safeReturnTo = returnToQuery.startsWith ('/materials') ? returnToQuery : ''
|
||||||
|
|
||||||
const navigate = useNavigate ()
|
const navigate = useNavigate ()
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ const MaterialNewPage: FC = () => {
|
|||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
|
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
|
||||||
toast ({ title: '送信成功!' })
|
toast ({ title: '送信成功!' })
|
||||||
navigate (returnToQuery || `/materials?tag=${ encodeURIComponent (tag) }`)
|
navigate (safeReturnTo || '/materials')
|
||||||
},
|
},
|
||||||
onError: error => {
|
onError: error => {
|
||||||
applyValidationError (error)
|
applyValidationError (error)
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする