このコミットが含まれているのは:
2026-06-27 05:37:58 +09:00
コミット 41a98ff725
5個のファイルの変更222行の追加0行の削除
+19
ファイルの表示
@@ -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
+62
ファイルの表示
@@ -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
+23
ファイルの表示
@@ -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
+66
ファイルの表示
@@ -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',
)
})
})
+52
ファイルの表示
@@ -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 ()
})
})