diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb
index a6d14fd..014cdde 100644
--- a/backend/spec/models/tag_spec.rb
+++ b/backend/spec/models/tag_spec.rb
@@ -1,6 +1,79 @@
require 'rails_helper'
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
let!(:target_tag) { create(:tag, category: :general) }
let!(:source_tag) { create(:tag, category: :general) }
diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb
index 2e6e432..18d5b73 100644
--- a/backend/spec/requests/posts_spec.rb
+++ b/backend/spec/requests/posts_spec.rb
@@ -736,16 +736,22 @@ RSpec.describe 'Posts API', type: :request do
)
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)
deprecated_parent = Tag.create!(
name: 'deprecated_parent',
category: :general,
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)
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)
post '/posts', params: post_write_params(
@@ -758,7 +764,7 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:created)
saved_names = Post.find(json.fetch('id')).tags.map(&:name)
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
context "when nico tag already exists in tags" do
diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb
index 8f092b3..62afeb8 100644
--- a/backend/spec/requests/tag_versions_spec.rb
+++ b/backend/spec/requests/tag_versions_spec.rb
@@ -21,6 +21,7 @@ RSpec.describe 'TagVersions API', type: :request do
event_type:,
name:,
category:,
+ deprecated_at: nil,
aliases: [],
parent_tags: [],
created_by_user:,
@@ -33,6 +34,7 @@ RSpec.describe 'TagVersions API', type: :request do
event_type: event_type,
name: name,
category: category,
+ deprecated_at: deprecated_at,
aliases: Array(aliases).join(' '),
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
created_by_user: created_by_user,
@@ -65,6 +67,7 @@ RSpec.describe 'TagVersions API', type: :request do
event_type: 'update',
name: 'new_tag_name',
category: 'meme',
+ deprecated_at: t_v2,
aliases: ['alias_shared', 'alias_new'],
parent_tags: [parent_shared, parent_new],
created_by_user: member,
@@ -133,6 +136,10 @@ RSpec.describe 'TagVersions API', type: :request do
'current' => 'meme',
'prev' => 'general'
)
+ expect(latest.fetch('deprecated_at')).to eq(
+ 'current' => t_v2.iso8601,
+ 'prev' => nil
+ )
expect(latest.fetch('aliases')).to include(
{ 'name' => 'alias_shared', 'type' => 'context' },
{ 'name' => 'alias_new', 'type' => 'added' },
@@ -178,6 +185,10 @@ RSpec.describe 'TagVersions API', type: :request do
'current' => 'general',
'prev' => nil
)
+ expect(first.fetch('deprecated_at')).to eq(
+ 'current' => nil,
+ 'prev' => nil
+ )
expect(first.fetch('aliases')).to include(
{ 'name' => 'alias_shared', 'type' => 'added' },
{ 'name' => 'alias_old', 'type' => 'added' }
diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb
index 26fe9c3..927b4a7 100644
--- a/backend/spec/requests/tags_spec.rb
+++ b/backend/spec/requests/tags_spec.rb
@@ -80,7 +80,7 @@ RSpec.describe 'Tags API', type: :request do
deprecated_tag = Tag.create!(
name: 'deprecated_filter',
category: :general,
- deprecated_at: Time.current
+ deprecated_at: 1.day.from_now
)
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)
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
expect {
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') }).not_to include('depth_deprecated_middle')
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
describe 'GET /tags/name/:name/materials' do
diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb
index 9b6e8c1..614180e 100644
--- a/backend/spec/requests/wiki_spec.rb
+++ b/backend/spec/requests/wiki_spec.rb
@@ -18,6 +18,13 @@ RSpec.describe 'Wiki API', type: :request do
created_by_user: user,
message: 'init')
end
+ let!(:tag) do
+ Tag.create!(
+ tag_name: tn,
+ category: :general,
+ deprecated_at: Time.zone.local(2026, 6, 1)
+ )
+ end
describe 'GET /wiki' 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.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
@@ -48,7 +57,8 @@ RSpec.describe 'Wiki API', type: :request do
expect(json).to include(
'id' => page.id,
- 'title' => 'spec_wiki_title')
+ 'title' => 'spec_wiki_title',
+ 'deprecated_at' => tag.deprecated_at.iso8601(3))
end
end
@@ -409,7 +419,11 @@ RSpec.describe 'Wiki API', type: :request do
'kind' => 'content',
'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).to have_key('timestamp')
@@ -479,6 +493,7 @@ RSpec.describe 'Wiki API', type: :request do
expect(json).to include(
'wiki_page_id' => page.id,
'title' => 'spec_wiki_title',
+ 'deprecated_at' => tag.deprecated_at.iso8601(3),
'older_revision_id' => rev_a.id,
'newer_revision_id' => rev_b.id
)
diff --git a/frontend/src/pages/wiki/WikiDetailPage.test.tsx b/frontend/src/pages/wiki/WikiDetailPage.test.tsx
new file mode 100644
index 0000000..4b7b8c7
--- /dev/null
+++ b/frontend/src/pages/wiki/WikiDetailPage.test.tsx
@@ -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 (
+
+ }/>
+ ,
+ { 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 ()
+ })
+})
diff --git a/frontend/src/pages/wiki/WikiDiffPage.test.tsx b/frontend/src/pages/wiki/WikiDiffPage.test.tsx
index 24d71eb..185b904 100644
--- a/frontend/src/pages/wiki/WikiDiffPage.test.tsx
+++ b/frontend/src/pages/wiki/WikiDiffPage.test.tsx
@@ -16,6 +16,7 @@ describe ('WikiDiffPage', () => {
api.apiGet.mockResolvedValueOnce ({
wikiPageId: 3,
title: '差分対象',
+ deprecatedAt: null,
olderRevisionId: 1,
newerRevisionId: 2,
diff: [
@@ -43,4 +44,26 @@ describe ('WikiDiffPage', () => {
expect (screen.getByText ('added 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 (
+
+ }/>
+ ,
+ { route: '/wiki/3/diff?from=1&to=2' },
+ )
+
+ expect (await screen.findByRole ('heading', {
+ name: '廃止 Wiki(廃止)',
+ })).toBeInTheDocument ()
+ })
})
diff --git a/frontend/src/pages/wiki/WikiHistoryPage.test.tsx b/frontend/src/pages/wiki/WikiHistoryPage.test.tsx
new file mode 100644
index 0000000..35aa8f5
--- /dev/null
+++ b/frontend/src/pages/wiki/WikiHistoryPage.test.tsx
@@ -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 ()
+
+ 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 ()
+ })
+})
diff --git a/frontend/src/pages/wiki/WikiSearchPage.test.tsx b/frontend/src/pages/wiki/WikiSearchPage.test.tsx
index fd87e1b..e831035 100644
--- a/frontend/src/pages/wiki/WikiSearchPage.test.tsx
+++ b/frontend/src/pages/wiki/WikiSearchPage.test.tsx
@@ -53,6 +53,10 @@ describe ('WikiSearchPage', () => {
renderWithProviders ()
- 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 ()
})
})