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 () }) })