タグ “廃止” 追加 (#378) #379

マージ済み
みてるぞ が 7 個のコミットを feature/378 から main へマージ 2026-06-22 08:40:07 +09:00
9個のファイルの変更342行の追加7行の削除
コミット 130d8b256a の変更だけを表示してゐます - すべてのコミットを表示
+73
ファイルの表示
@@ -1,6 +1,79 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Tag, type: :model do RSpec.describe Tag, type: :model do
describe '.normalise_tags!' do
it 'rejects deprecated tags when deny_deprecated is enabled' do
tag_name = TagName.create!(name: 'normalise deprecated tag')
deprecated_tag = Tag.create!(
tag_name:,
category: :general,
deprecated_at: 1.day.from_now
)
expect {
described_class.normalise_tags!(
[deprecated_tag.name],
deny_deprecated: true
)
}.to raise_error(Tag::DeprecatedTagNormalisationError) { |error|
expect(error.tag_names).to eq([deprecated_tag.name])
}
end
end
describe '.expand_parent_tags' do
it 'expands through multiple deprecated parents to an active ancestor' do
child = create(:tag, name: 'expand_child')
deprecated_parent = create(
:tag,
name: 'expand_deprecated_parent',
deprecated_at: Time.current
)
deprecated_grandparent = create(
:tag,
name: 'expand_deprecated_grandparent',
deprecated_at: Time.current
)
active_ancestor = create(:tag, name: 'expand_active_ancestor')
TagImplication.create!(tag: child, parent_tag: deprecated_parent)
TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor)
expanded = described_class.expand_parent_tags([child])
expect(expanded).to include(
child,
deprecated_parent,
deprecated_grandparent,
active_ancestor
)
expect(expanded.reject(&:deprecated?)).to contain_exactly(child, active_ancestor)
end
it 'terminates when implications contain a cycle' do
first = create(:tag, name: 'expand_cycle_first')
second = create(:tag, name: 'expand_cycle_second')
TagImplication.create!(tag: first, parent_tag: second)
TagImplication.create!(tag: second, parent_tag: first)
expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second)
end
end
describe 'deprecated validation' do
it 'rejects deprecated nico tags' do
tag = build(
:tag,
name: 'nico:deprecated_validation',
category: :nico,
deprecated_at: Time.current
)
expect(tag).not_to be_valid
expect(tag.errors[:deprecated_at]).to include('ニコタグは廃止できません.')
end
end
describe '.merge_tags!' do describe '.merge_tags!' do
let!(:target_tag) { create(:tag, category: :general) } let!(:target_tag) { create(:tag, category: :general) }
let!(:source_tag) { create(:tag, category: :general) } let!(:source_tag) { create(:tag, category: :general) }
+9 -3
ファイルの表示
@@ -736,16 +736,22 @@ RSpec.describe 'Posts API', type: :request do
) )
end end
it 'expands through deprecated parent tags and saves active ancestors' do it 'expands through multiple deprecated parent tags and saves active ancestors' do
child = Tag.create!(name: 'active_child', category: :general) child = Tag.create!(name: 'active_child', category: :general)
deprecated_parent = Tag.create!( deprecated_parent = Tag.create!(
name: 'deprecated_parent', name: 'deprecated_parent',
category: :general, category: :general,
deprecated_at: Time.current deprecated_at: Time.current
) )
deprecated_grandparent = Tag.create!(
name: 'deprecated_grandparent',
category: :general,
deprecated_at: Time.current
)
active_grandparent = Tag.create!(name: 'active_grandparent', category: :general) active_grandparent = Tag.create!(name: 'active_grandparent', category: :general)
TagImplication.create!(tag: child, parent_tag: deprecated_parent) TagImplication.create!(tag: child, parent_tag: deprecated_parent)
TagImplication.create!(tag: deprecated_parent, parent_tag: active_grandparent) TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_grandparent)
sign_in_as(member) sign_in_as(member)
post '/posts', params: post_write_params( post '/posts', params: post_write_params(
@@ -758,7 +764,7 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
saved_names = Post.find(json.fetch('id')).tags.map(&:name) saved_names = Post.find(json.fetch('id')).tags.map(&:name)
expect(saved_names).to include('active_child', 'active_grandparent') expect(saved_names).to include('active_child', 'active_grandparent')
expect(saved_names).not_to include('deprecated_parent') expect(saved_names).not_to include('deprecated_parent', 'deprecated_grandparent')
end end
context "when nico tag already exists in tags" do context "when nico tag already exists in tags" do
+11
ファイルの表示
@@ -21,6 +21,7 @@ RSpec.describe 'TagVersions API', type: :request do
event_type:, event_type:,
name:, name:,
category:, category:,
deprecated_at: nil,
aliases: [], aliases: [],
parent_tags: [], parent_tags: [],
created_by_user:, created_by_user:,
@@ -33,6 +34,7 @@ RSpec.describe 'TagVersions API', type: :request do
event_type: event_type, event_type: event_type,
name: name, name: name,
category: category, category: category,
deprecated_at: deprecated_at,
aliases: Array(aliases).join(' '), aliases: Array(aliases).join(' '),
parent_tag_ids: Array(parent_tags).map(&:id).join(' '), parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
created_by_user: created_by_user, created_by_user: created_by_user,
@@ -65,6 +67,7 @@ RSpec.describe 'TagVersions API', type: :request do
event_type: 'update', event_type: 'update',
name: 'new_tag_name', name: 'new_tag_name',
category: 'meme', category: 'meme',
deprecated_at: t_v2,
aliases: ['alias_shared', 'alias_new'], aliases: ['alias_shared', 'alias_new'],
parent_tags: [parent_shared, parent_new], parent_tags: [parent_shared, parent_new],
created_by_user: member, created_by_user: member,
@@ -133,6 +136,10 @@ RSpec.describe 'TagVersions API', type: :request do
'current' => 'meme', 'current' => 'meme',
'prev' => 'general' 'prev' => 'general'
) )
expect(latest.fetch('deprecated_at')).to eq(
'current' => t_v2.iso8601,
'prev' => nil
)
expect(latest.fetch('aliases')).to include( expect(latest.fetch('aliases')).to include(
{ 'name' => 'alias_shared', 'type' => 'context' }, { 'name' => 'alias_shared', 'type' => 'context' },
{ 'name' => 'alias_new', 'type' => 'added' }, { 'name' => 'alias_new', 'type' => 'added' },
@@ -178,6 +185,10 @@ RSpec.describe 'TagVersions API', type: :request do
'current' => 'general', 'current' => 'general',
'prev' => nil 'prev' => nil
) )
expect(first.fetch('deprecated_at')).to eq(
'current' => nil,
'prev' => nil
)
expect(first.fetch('aliases')).to include( expect(first.fetch('aliases')).to include(
{ 'name' => 'alias_shared', 'type' => 'added' }, { 'name' => 'alias_shared', 'type' => 'added' },
{ 'name' => 'alias_old', 'type' => 'added' } { 'name' => 'alias_old', 'type' => 'added' }
+112 -1
ファイルの表示
@@ -80,7 +80,7 @@ RSpec.describe 'Tags API', type: :request do
deprecated_tag = Tag.create!( deprecated_tag = Tag.create!(
name: 'deprecated_filter', name: 'deprecated_filter',
category: :general, category: :general,
deprecated_at: Time.current deprecated_at: 1.day.from_now
) )
active_tag = Tag.create!(name: 'active_filter', category: :general) active_tag = Tag.create!(name: 'active_filter', category: :general)
@@ -473,6 +473,32 @@ RSpec.describe 'Tags API', type: :request do
expect(versions.second.created_by_user_id).to eq(member_user.id) expect(versions.second.created_by_user_id).to eq(member_user.id)
end end
it 'updates deprecated state and records it in tag versions' do
expect {
patch "/tags/#{ tag.id }", params: { deprecated: '1' }
}.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:ok)
expect(tag.reload.deprecated_at).to be_present
versions = tag.tag_versions.order(:version_no)
expect(versions.first.deprecated_at).to be_nil
expect(versions.second.deprecated_at).to eq(tag.deprecated_at)
expect(json.fetch('deprecated_at')).to be_present
end
it 'rejects deprecating a nico tag' do
nico_tag = Tag.create!(name: 'nico:deprecated_update', category: :nico)
patch "/tags/#{ nico_tag.id }", params: { deprecated: '1' }
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.deprecated_at).to be_nil
expect(json.fetch('errors')).to include(
'deprecated' => ['ニコタグは廃止できません.']
)
end
it 'returns 422 when changing normal tag category to nico' do it 'returns 422 when changing normal tag category to nico' do
expect { expect {
patch "/tags/#{tag.id}", params: { category: 'nico' } patch "/tags/#{tag.id}", params: { category: 'nico' }
@@ -641,6 +667,91 @@ RSpec.describe 'Tags API', type: :request do
expect(json.map { |item| item.fetch('name') }).to eq(['depth_visible_descendant']) expect(json.map { |item| item.fetch('name') }).to eq(['depth_visible_descendant'])
expect(json.map { |item| item.fetch('name') }).not_to include('depth_deprecated_middle') expect(json.map { |item| item.fetch('name') }).not_to include('depth_deprecated_middle')
end end
it 'passes through multiple deprecated tags for roots and has_children' do
active_child = Tag.create!(
name: 'depth_active_child_below_deprecated',
category: :character
)
deprecated_parent = Tag.create!(
name: 'depth_deprecated_parent',
category: :character,
deprecated_at: Time.current
)
deprecated_grandparent = Tag.create!(
name: 'depth_deprecated_grandparent',
category: :material,
deprecated_at: Time.current
)
active_ancestor = Tag.create!(
name: 'depth_active_ancestor',
category: :meme
)
TagImplication.create!(tag: active_child, parent_tag: deprecated_parent)
TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor)
get '/tags/with-depth'
root_names = json.map { |item| item.fetch('name') }
expect(root_names).to include('depth_active_ancestor')
expect(root_names).not_to include('depth_active_child_below_deprecated')
ancestor_json = json.find { |item| item.fetch('id') == active_ancestor.id }
expect(ancestor_json.fetch('has_children')).to eq(true)
get '/tags/with-depth', params: { parent: active_ancestor.id }
expect(json.map { |item| item.fetch('name') }).to include(
'depth_active_child_below_deprecated'
)
expect(json.map { |item| item.fetch('name') }).not_to include(
'depth_deprecated_parent',
'depth_deprecated_grandparent'
)
end
it 'treats an active tag with only deprecated ancestors as a root' do
active_child = Tag.create!(
name: 'depth_root_below_deprecated',
category: :character
)
deprecated_parent = Tag.create!(
name: 'depth_root_deprecated_parent',
category: :material,
deprecated_at: Time.current
)
TagImplication.create!(tag: active_child, parent_tag: deprecated_parent)
get '/tags/with-depth'
expect(json.map { |item| item.fetch('name') }).to include(
'depth_root_below_deprecated'
)
expect(json.map { |item| item.fetch('name') }).not_to include(
'depth_root_deprecated_parent'
)
end
it 'terminates when deprecated implications contain a cycle' do
first = Tag.create!(
name: 'depth_cycle_first',
category: :character,
deprecated_at: Time.current
)
second = Tag.create!(
name: 'depth_cycle_second',
category: :material,
deprecated_at: Time.current
)
TagImplication.create!(tag: first, parent_tag: root_material)
TagImplication.create!(tag: second, parent_tag: first)
TagImplication.create!(tag: first, parent_tag: second)
get '/tags/with-depth', params: { parent: root_material.id }
expect(response).to have_http_status(:ok)
expect(json).to eq([])
end
end end
describe 'GET /tags/name/:name/materials' do describe 'GET /tags/name/:name/materials' do
+17 -2
ファイルの表示
@@ -18,6 +18,13 @@ RSpec.describe 'Wiki API', type: :request do
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
end end
let!(:tag) do
Tag.create!(
tag_name: tn,
category: :general,
deprecated_at: Time.zone.local(2026, 6, 1)
)
end
describe 'GET /wiki' do describe 'GET /wiki' do
it 'returns wiki pages with title' do it 'returns wiki pages with title' do
@@ -30,6 +37,8 @@ RSpec.describe 'Wiki API', type: :request do
expect(json[0]).to have_key('title') expect(json[0]).to have_key('title')
expect(json.map { |p| p['title'] }).to include('spec_wiki_title') expect(json.map { |p| p['title'] }).to include('spec_wiki_title')
wiki_json = json.find { |item| item.fetch('id') == page.id }
expect(wiki_json.fetch('deprecated_at')).to eq(tag.deprecated_at.iso8601(3))
end end
end end
@@ -48,7 +57,8 @@ RSpec.describe 'Wiki API', type: :request do
expect(json).to include( expect(json).to include(
'id' => page.id, 'id' => page.id,
'title' => 'spec_wiki_title') 'title' => 'spec_wiki_title',
'deprecated_at' => tag.deprecated_at.iso8601(3))
end end
end end
@@ -409,7 +419,11 @@ RSpec.describe 'Wiki API', type: :request do
'kind' => 'content', 'kind' => 'content',
'message' => 'r2' 'message' => 'r2'
) )
expect(top['wiki_page']).to include('id' => page.id, 'title' => 'spec_wiki_title') expect(top['wiki_page']).to include(
'id' => page.id,
'title' => 'spec_wiki_title',
'deprecated_at' => tag.deprecated_at.iso8601(3)
)
expect(top['user']).to include('id' => user.id, 'name' => user.name) expect(top['user']).to include('id' => user.id, 'name' => user.name)
expect(top).to have_key('timestamp') expect(top).to have_key('timestamp')
@@ -479,6 +493,7 @@ RSpec.describe 'Wiki API', type: :request do
expect(json).to include( expect(json).to include(
'wiki_page_id' => page.id, 'wiki_page_id' => page.id,
'title' => 'spec_wiki_title', 'title' => 'spec_wiki_title',
'deprecated_at' => tag.deprecated_at.iso8601(3),
'older_revision_id' => rev_a.id, 'older_revision_id' => rev_a.id,
'newer_revision_id' => rev_b.id 'newer_revision_id' => rev_b.id
) )
+54
ファイルの表示
@@ -0,0 +1,54 @@
import { screen } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
import { buildTag, buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const wikiApi = vi.hoisted (() => ({
fetchWikiPage: vi.fn (),
fetchWikiPageByTitle: vi.fn (),
}))
const tagsApi = vi.hoisted (() => ({
fetchTagByName: vi.fn (),
}))
const postsApi = vi.hoisted (() => ({
fetchPosts: vi.fn (),
}))
vi.mock ('@/lib/wiki', () => wikiApi)
vi.mock ('@/lib/tags', () => tagsApi)
vi.mock ('@/lib/posts', () => postsApi)
describe ('WikiDetailPage', () => {
it ('renders deprecated state outside the wiki title link', async () => {
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce (buildWikiPage ({
title: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
}))
tagsApi.fetchTagByName.mockResolvedValueOnce (buildTag ({
name: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
}))
postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 })
renderWithProviders (
<Routes>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
</Routes>,
{ route: '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0' },
)
const marker = await screen.findByText ('(廃止)')
const heading = marker.closest ('h1')
const link = screen.getByRole ('link', { name: '旧タグ' })
expect (heading).not.toBeNull ()
expect (heading!).toHaveTextContent ('旧タグ(廃止)')
expect (link).toBeInTheDocument ()
expect (marker.closest ('a')).toBeNull ()
})
})
+23
ファイルの表示
@@ -16,6 +16,7 @@ describe ('WikiDiffPage', () => {
api.apiGet.mockResolvedValueOnce ({ api.apiGet.mockResolvedValueOnce ({
wikiPageId: 3, wikiPageId: 3,
title: '差分対象', title: '差分対象',
deprecatedAt: null,
olderRevisionId: 1, olderRevisionId: 1,
newerRevisionId: 2, newerRevisionId: 2,
diff: [ diff: [
@@ -43,4 +44,26 @@ describe ('WikiDiffPage', () => {
expect (screen.getByText ('added line')).toBeInTheDocument () expect (screen.getByText ('added line')).toBeInTheDocument ()
expect (screen.getByText ('removed line')).toBeInTheDocument () expect (screen.getByText ('removed line')).toBeInTheDocument ()
}) })
it ('appends deprecated state to the wiki title', async () => {
api.apiGet.mockResolvedValueOnce ({
wikiPageId: 3,
title: '廃止 Wiki',
deprecatedAt: '2026-06-01T00:00:00.000Z',
olderRevisionId: 1,
newerRevisionId: 2,
diff: [],
})
renderWithProviders (
<Routes>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
</Routes>,
{ route: '/wiki/3/diff?from=1&to=2' },
)
expect (await screen.findByRole ('heading', {
name: '廃止 Wiki(廃止)',
})).toBeInTheDocument ()
})
}) })
+38
ファイルの表示
@@ -0,0 +1,38 @@
import { screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('WikiHistoryPage', () => {
it ('renders deprecated state outside the wiki title link', async () => {
api.apiGet.mockResolvedValueOnce ([{
revisionId: 2,
pred: 1,
succ: null,
wikiPage: {
id: 3,
title: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
},
user: { id: 4, name: 'tester' },
kind: 'content',
message: 'updated',
timestamp: '2026-06-02T00:00:00.000Z',
}])
renderWithProviders (<WikiHistoryPage/>)
const link = await screen.findByRole ('link', { name: '旧タグ' })
const marker = screen.getByText ('(廃止)')
expect (link).toHaveAttribute ('href', '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0?version=2')
expect (marker.closest ('a')).toBeNull ()
})
})
+5 -1
ファイルの表示
@@ -53,6 +53,10 @@ describe ('WikiSearchPage', () => {
renderWithProviders (<WikiSearchPage/>) renderWithProviders (<WikiSearchPage/>)
expect (await screen.findByRole ('link', { name: '旧タグ(廃止)' })).toBeInTheDocument () const link = await screen.findByRole ('link', { name: '旧タグ' })
const marker = screen.getByText ('(廃止)')
expect (link).toBeInTheDocument ()
expect (marker.closest ('a')).toBeNull ()
}) })
}) })