diff --git a/backend/spec/models/material_spec.rb b/backend/spec/models/material_spec.rb
new file mode 100644
index 0000000..f1f2d15
--- /dev/null
+++ b/backend/spec/models/material_spec.rb
@@ -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
diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb
index f2a7581..fcabde4 100644
--- a/backend/spec/requests/materials_spec.rb
+++ b/backend/spec/requests/materials_spec.rb
@@ -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
diff --git a/backend/spec/services/material_file_sha256_spec.rb b/backend/spec/services/material_file_sha256_spec.rb
new file mode 100644
index 0000000..37dde5c
--- /dev/null
+++ b/backend/spec/services/material_file_sha256_spec.rb
@@ -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
diff --git a/frontend/src/pages/materials/MaterialListPage.test.tsx b/frontend/src/pages/materials/MaterialListPage.test.tsx
index 4c0992b..0c4c26e 100644
--- a/frontend/src/pages/materials/MaterialListPage.test.tsx
+++ b/frontend/src/pages/materials/MaterialListPage.test.tsx
@@ -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 (
+