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 ( + , + { 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 (, { route: '/materials?tag_id=999' }) + + expect (await screen.findByText ('選択中タグが見つかりません.')).toBeInTheDocument () + expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute ( + 'href', + '/materials?material_filter=present', + ) + }) }) diff --git a/frontend/src/pages/materials/MaterialNewPage.test.tsx b/frontend/src/pages/materials/MaterialNewPage.test.tsx index c67e55b..1d3a1e8 100644 --- a/frontend/src/pages/materials/MaterialNewPage.test.tsx +++ b/frontend/src/pages/materials/MaterialNewPage.test.tsx @@ -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
{location.pathname + location.search}
+} + 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 ( + + }/> + }/> + , + { + 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 ( + + }/> + }/> + , + { + 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 () + }) })