From 1e464107a858912240199b9f93727fb466eed3f7 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 15 Oct 2026 20:20:00 +0900 Subject: [PATCH] #431 --- .../spec/db/tag_description_migration_spec.rb | 34 -- backend/spec/db/tag_group_migration_spec.rb | 119 ------- backend/spec/factories/tag_names.rb | 2 + backend/spec/factories/tags.rb | 13 +- backend/spec/factories/wiki_pages.rb | 9 - backend/spec/models/shared_tag_ids_spec.rb | 93 ++---- backend/spec/models/tag_group_spec.rb | 23 ++ .../models/tag_name_sanitisation_rule_spec.rb | 19 -- backend/spec/models/tag_name_spec.rb | 34 +- backend/spec/models/tag_spec.rb | 60 +--- backend/spec/requests/materials_spec.rb | 15 +- .../requests/tag_description_wiki_spec.rb | 90 +----- .../tag_description_wiki_write_spec.rb | 306 ++++-------------- .../spec/requests/tag_descriptions_spec.rb | 88 ++--- backend/spec/requests/tag_group_wiki_spec.rb | 156 +-------- .../requests/tag_group_wiki_write_spec.rb | 57 ++-- .../tag_group_write_integrity_spec.rb | 163 ++-------- backend/spec/requests/tag_groups_spec.rb | 58 ++-- .../spec/requests/tag_resource_ids_spec.rb | 126 +++----- backend/spec/requests/tag_versions_spec.rb | 11 +- .../tag_wiki_history_integrity_spec.rb | 30 +- backend/spec/requests/tags_spec.rb | 68 ++-- backend/spec/requests/wiki_conflict_spec.rb | 12 +- .../requests/wiki_creation_freeze_spec.rb | 18 +- .../requests/wiki_history_integrity_spec.rb | 170 ---------- .../spec/requests/wiki_index_order_spec.rb | 6 +- backend/spec/requests/wiki_spec.rb | 98 ++---- .../requests/wiki_title_collision_spec.rb | 21 +- .../requests/wiki_unmigrated_contract_spec.rb | 85 +---- ...shared_resource_id_allocator_mysql_spec.rb | 39 ++- .../shared_resource_id_allocator_spec.rb | 116 ++++++- .../services/wiki/commit_integrity_spec.rb | 185 ----------- backend/spec/services/wiki/commit_spec.rb | 151 --------- .../services/wiki_version_recorder_spec.rb | 100 ------ backend/spec/support/tag_group_records.rb | 45 +-- backend/spec/support/wiki_records.rb | 28 +- 36 files changed, 566 insertions(+), 2082 deletions(-) delete mode 100644 backend/spec/db/tag_description_migration_spec.rb delete mode 100644 backend/spec/db/tag_group_migration_spec.rb delete mode 100644 backend/spec/factories/wiki_pages.rb create mode 100644 backend/spec/models/tag_group_spec.rb delete mode 100644 backend/spec/requests/wiki_history_integrity_spec.rb delete mode 100644 backend/spec/services/wiki/commit_integrity_spec.rb delete mode 100644 backend/spec/services/wiki/commit_spec.rb delete mode 100644 backend/spec/services/wiki_version_recorder_spec.rb diff --git a/backend/spec/db/tag_description_migration_spec.rb b/backend/spec/db/tag_description_migration_spec.rb deleted file mode 100644 index c34c2a1..0000000 --- a/backend/spec/db/tag_description_migration_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -require 'rails_helper' -require_relative '../../db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages' - -RSpec.describe 'Tag description migration identity' do - it 'preserves every revision ID and repeated body in an independent sequence' do - user = create_member_user! - tag_name = create(:tag_name, name: 'description_migration') - page = Wiki::Commit.create_legacy_content!( - tag_name:, body: 'A', created_by_user: user) - revisions = [page.current_revision] - ['A', 'B', 'B', 'A'].each do |body| - revisions << Wiki::Commit.legacy_content!( - page:, body:, created_user: user, base_revision_id: revisions.last.id) - end - tag = create(:tag, primary_tag_name: tag_name) - legacy_state = [page.reload.attributes, revisions.map(&:attributes)] - expect(page.wiki_versions.count).to eq(3) - - migration = MigrateTagDescriptionsFromWikiPages.new - migration.suppress_messages { migration.up } - - versions = TagDescriptionVersion.where(tag_id: tag.id, language_code: 'ja') - .order(:version_no) - expect(versions.pluck(:id)).to eq(revisions.map(&:id)) - expect(versions.pluck(:version_no)).to eq([1, 2, 3, 4, 5]) - expect(versions.pluck(:body)).to eq(['A', 'A', 'B', 'B', 'A']) - expect(versions.pluck(:created_by_user_id)).to eq([user.id] * 5) - expect(versions.pluck(:created_at)).to eq(revisions.map(&:created_at)) - expect(TagDescription.find(page.id)).to have_attributes( - tag_id: tag.id, language_code: 'ja', version_no: 5, body: 'A') - expect([page.reload.attributes, revisions.map { |revision| revision.reload.attributes }]) - .to eq(legacy_state) - end -end diff --git a/backend/spec/db/tag_group_migration_spec.rb b/backend/spec/db/tag_group_migration_spec.rb deleted file mode 100644 index c9e7566..0000000 --- a/backend/spec/db/tag_group_migration_spec.rb +++ /dev/null @@ -1,119 +0,0 @@ -require 'rails_helper' - -RSpec.describe 'Tag group migration identity and structure' do - include TagGroupRecords - - let!(:user) { create_member_user! } - - before { I18n.locale = :ja } - - it 'preserves page and revision IDs, timestamps and repeated historical structures' do - tag = group_tag('洗澡鹿') - body = "説明文\n\n- 洗澡鹿\n- 洗澡鹿" - page = legacy_group('伊地知ニジカ亜種', body) - revisions = [page.current_revision] - [body, '# 空の区分', body].each do |text| - revisions << Wiki::Commit.legacy_content!(page:, body: text, created_user: user) - end - before_state = group_state.except(*TagGroupRecords::GROUP_TABLES) - - migrate_groups - - versions = group_rows('tag_group_versions', tag_group_id: page.id) - .sort_by { _1.fetch('version_no') } - expect(versions.map { _1.fetch('id') }).to eq(revisions.map(&:id)) - expect(versions.map { _1.fetch('version_no') }).to eq([1, 2, 3, 4]) - expect(versions.map { _1.fetch('created_at') }).to eq(revisions.map(&:created_at)) - expect(versions.map { _1.fetch('created_by_user_id') }).to eq([user.id] * 4) - expect(versions.map { _1.fetch('event_type') }).to eq(['create', 'update', 'update', 'update']) - expect(group_version(page.id, 1).fetch('placements')).to eq( - group_version(page.id, 2).fetch('placements')) - expect(group_version(page.id, 3).fetch('placements')).to eq([ - { 'section_name' => '空の区分', 'description' => nil, 'children' => [] }]) - expect_group_snapshot( - page.id, number: 4, description: '説明文', - members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }], - placements: [{ 'tag_id' => tag.id, 'children' => [] }] * 2) - expect(group_rows('tag_groups', id: page.id).sole).to include( - 'created_at' => page.created_at, 'updated_at' => page.reload.updated_at) - expect(group_state.except(*TagGroupRecords::GROUP_TABLES)).to eq(before_state) - end - - it 'migrates nested list Sections and child groups as different placement kinds' do - series = group_tag('ぼざろクリーチャーシリーズ') - deer = group_tag('洗澡鹿') - nijika = group_tag('伊地知ニジカ') - child = legacy_group('伊地知ニジカ亜種', <<~MARKDOWN) - - ぼざろクリーチャーシリーズ - - 伊地知ニジカ亜種: - - 洗澡鹿 - MARKDOWN - parent = legacy_group('ぼざろクリーチャーシリーズのキャラクター', <<~MARKDOWN) - - ぼざろクリーチャーシリーズ - - 伊地知ニジカ - - タググループ:伊地知ニジカ亜種 - MARKDOWN - - migrate_groups - - expect_group_snapshot( - child.id, number: 1, - members: [series, deer].map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } }, - placements: [{ 'tag_id' => series.id, 'children' => [ - { 'section_name' => '伊地知ニジカ亜種', 'description' => nil, - 'children' => [{ 'tag_id' => deer.id, 'children' => [] }] }] }]) - expect_group_snapshot( - parent.id, number: 1, - members: [ - { 'tag_id' => series.id, 'version_no' => series.version_no }, - { 'tag_id' => nijika.id, 'version_no' => nijika.version_no }, - { 'tag_group_id' => child.id, 'version_no' => 1 }], - placements: [{ 'tag_id' => series.id, 'children' => [ - { 'tag_id' => nijika.id, 'children' => [ - { 'tag_group_id' => child.id, 'children' => [] }] }] }]) - end - - it 'preserves heading order, group paragraphs and Section descriptions' do - names = ['タグ希望', 'キャラクター名希望', '楽曲情報希望', 'bot操作', 'ニジラー情報不詳'] - tags = names.map { group_tag(_1) } - markdown = <<~MARKDOWN - 全体の説明 - - # 要求 - 要求の説明 - - - タグ希望 - - キャラクター名希望 - - 楽曲情報希望 - - # 情報 - - bot操作 - - ニジラー情報不詳 - MARKDOWN - page = legacy_group('メタタグ', markdown) - expect(page.current_revision.body).to eq(markdown.chomp) - - migrate_groups - - leaves = tags.map { { 'tag_id' => _1.id, 'children' => [] } } - leaves.first['children'] = leaves[1..2] - expect_group_snapshot( - page.id, number: 1, description: '全体の説明', - members: tags.map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } }, - placements: [ - { 'section_name' => '要求', 'description' => '要求の説明', - 'children' => [leaves.first] }, - { 'section_name' => '情報', 'description' => nil, - 'children' => leaves[3..4] }]) - end - - it 'leaves Help in the legacy domain' do - page = Wiki::Commit.create_legacy_content!( - tag_name: create(:tag_name, name: 'Help:移行対象外'), - body: 'Help 本文', created_by_user: user) - before_state = group_state - migrate_groups - expect(group_state).to eq(before_state) - expect(page.reload.body).to eq('Help 本文') - end -end diff --git a/backend/spec/factories/tag_names.rb b/backend/spec/factories/tag_names.rb index 0e98e3b..f5820cc 100644 --- a/backend/spec/factories/tag_names.rb +++ b/backend/spec/factories/tag_names.rb @@ -6,6 +6,8 @@ FactoryBot.define do auto_generated { false } sequence(:name) { |number| "tag-#{ SecureRandom.hex(4) }-#{ number }" } + association :tag, primary_tag_name: nil + trait :alias do primary_flg { false } end diff --git a/backend/spec/factories/tags.rb b/backend/spec/factories/tags.rb index d4ac59b..a01a84c 100644 --- a/backend/spec/factories/tags.rb +++ b/backend/spec/factories/tags.rb @@ -4,7 +4,7 @@ FactoryBot.define do primary_name { nil } primary_tag_name do attributes = primary_name.nil? ? { } : { name: primary_name } - build(:tag_name, **attributes) + build(:tag_name, tag: nil, **attributes) end end @@ -12,17 +12,16 @@ FactoryBot.define do post_count { 0 } after(:build) do |tag, evaluator| + next unless evaluator.primary_tag_name + evaluator.primary_tag_name.primary_flg = true + evaluator.primary_tag_name.tag = tag tag.tag_names.target << evaluator.primary_tag_name end - before(:create) do |tag, evaluator| - evaluator.primary_tag_name.save! - # Compatibility write only; ownership is established through tag_id below. - tag.tag_name_id = evaluator.primary_tag_name.id - end - after(:create) do |tag, evaluator| + next unless evaluator.primary_tag_name + evaluator.primary_tag_name.update!(tag:, primary_flg: true) Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code).to_a diff --git a/backend/spec/factories/wiki_pages.rb b/backend/spec/factories/wiki_pages.rb deleted file mode 100644 index 7f5f41a..0000000 --- a/backend/spec/factories/wiki_pages.rb +++ /dev/null @@ -1,9 +0,0 @@ -FactoryBot.define do - factory :wiki_page do - title { "TestPage" } - association :created_user, factory: :user - association :updated_user, factory: :user - - body { ' ' } - end -end diff --git a/backend/spec/models/shared_tag_ids_spec.rb b/backend/spec/models/shared_tag_ids_spec.rb index 96ef653..378eebc 100644 --- a/backend/spec/models/shared_tag_ids_spec.rb +++ b/backend/spec/models/shared_tag_ids_spec.rb @@ -1,84 +1,37 @@ require 'rails_helper' RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do - include TagGroupRecords - let(:user) { create_member_user! } + it 'allocates distinct increasing IDs across the two tables' do + tag = create(:tag) + external = create(:external_tag) + next_tag = create(:tag) - def create_resource resource - if resource == :tag - Tag.find_or_create_by_tag_name!(Locale.nipponese, 'shared_id_new', category: :general) - else - ExternalTag.create!(platform: :nico, name: 'shared_id_new') - end + expect(external.id).to be > tag.id + expect(next_tag.id).to be > external.id end - [:tag, :external_tag].each do |resource| - it "starts #{ resource } at 1 when both tables are empty" do - expect(Tag.count).to eq(0) - expect(ExternalTag.count).to eq(0) - expect(create_resource(resource).id).to eq(1) - end + it 'does not reuse physically deleted IDs in either table' do + tag = create(:tag) + external = create(:external_tag) + TagName.where(tag_id: tag.id).delete_all + tag.destroy! + external.destroy! - it "allocates #{ resource } above the other table's maximum" do - create(:tag, id: resource == :tag ? 1 : 20) - create(:external_tag, id: resource == :external_tag ? 1 : 20) - - expect(create_resource(resource).id).to eq(21) - end - - it "uses the current maximum after deleting earlier #{ resource } records" do - tag = create(:tag, id: 100) - TagName.where(tag_id: tag.id).update_all(tag_id: nil) - tag.destroy! - create(:external_tag, id: 200).destroy! - expect(Tag.unscoped.count).to eq(0) - expect(ExternalTag.unscoped.count).to eq(0) - expect(create_resource(resource).id).to eq(1) - end - - it "preserves legacy overlaps and gaps when creating #{ resource }" do - [1, 2, 5].each { create(:tag, id: _1) } - [1, 3, 8].each { create(:external_tag, id: _1) } - tags = Tag.order(:id).map(&:attributes) - external_tags = ExternalTag.order(:id).map(&:attributes) - - record = create_resource(resource) - - aggregate_failures do - expect(record.id).to eq(9) - expect(Tag.where(id: [1, 2, 5]).order(:id).map(&:attributes)).to eq(tags) - expect(ExternalTag.where(id: [1, 3, 8]).order(:id).map(&:attributes)).to eq(external_tags) - expect(Tag.exists?(id: 3)).to be(false) - expect(ExternalTag.exists?(id: 2)).to be(false) - end - end - - it "ignores the description and group ID space when creating #{ resource }" do - tag = create(:tag, id: 1) - create(:external_tag, id: 2) - TagDescription.create!(id: 100, tag:, language_code: 'ja', body: '', version_no: 1) - seed_group(name: 'separate_space', id: 200) - - expect(create_resource(resource).id).to eq(3) - end + expect(create(:tag).id).to be > external.id + expect(create(:external_tag).id).to be > external.id end - it 'preserves overlapping IDs during updates without rewriting the other resource' do - tag = create(:tag, id: 5) - external = create(:external_tag, id: 5) + it 'retains existing overlapping IDs without renumbering on update or insert' do + tag = create(:tag) + external = create(:external_tag) + external.update_columns(id: tag.id) tag.update!(category: :meta) external.update!(name: 'updated_external') + before_tag = tag.reload.attributes + before_external = external.reload.attributes - expect(tag.reload.id).to eq(5) - expect(external.reload.id).to eq(5) - expect(tag.category).to eq('meta') - expect(external.name).to eq('updated_external') - expect(Tag.count).to eq(1) - expect(ExternalTag.count).to eq(1) - end - - it 'allocates distinct IDs for successive creations across both tables' do - records = [:tag, :external_tag].map { create_resource(_1) } - expect(records.map(&:id)).to eq([1, 2]) + expect(create(:tag).id).to be > tag.id + expect(tag.reload.attributes).to eq(before_tag) + expect(external.reload.attributes).to eq(before_external) end end diff --git a/backend/spec/models/tag_group_spec.rb b/backend/spec/models/tag_group_spec.rb new file mode 100644 index 0000000..ee52647 --- /dev/null +++ b/backend/spec/models/tag_group_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe TagGroup, type: :model do + let(:actor) { create_member_user! } + + def create_group name:, members: [], placements: [] + TagGroupOperation.create!(name:, description: nil, members:, placements:, actor:) + end + + it 'retains version IDs and snapshots after destroying an empty group' do + group = create_group(name: 'history owner') + versions = group.versions.order(:version_no).map(&:attributes) + + expect { group.destroy! }.to change(described_class, :count).by(-1) + .and change(TagGroupVersion, :count).by(0) + + expect(TagGroupVersion.where(tag_group_id: group.id).order(:version_no).map(&:attributes)) + .to eq(versions) + expect(TagGroupRepr.historical(TagGroupVersion.find(versions.first.fetch('id')))) + .to include('id' => group.id, 'name' => 'history owner', 'members' => []) + end + +end diff --git a/backend/spec/models/tag_name_sanitisation_rule_spec.rb b/backend/spec/models/tag_name_sanitisation_rule_spec.rb index 9cebc51..cec475d 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -98,25 +98,6 @@ RSpec.describe TagNameSanitisationRule, type: :model do end end - context 'when the source tag_name has a tag and the existing one has no tag' do - let!(:existing) { create(:tag_name, name: 'foobar') } - let!(:source_tag) { create(:tag, primary_name: 'tmp', category: :general) } - let!(:source_tag_name_id) { primary_tag_name_for(source_tag, 'ja').id } - - before do - primary_tag_name_for(source_tag, 'ja').update_columns( - name: 'foo_bar', updated_at: Time.current) - end - - it 'moves the tag to the existing tag_name' do - described_class.apply! - expect(existing.reload).to have_attributes( - tag_id: source_tag.id, language_code: 'ja', primary_flg: true) - expect(primary_tag_name_for(source_tag, 'ja')).to eq(existing) - expect(primary_tag_name_for(source_tag.reload, 'ja')).to eq(existing) - expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false) - end - end context 'when the sanitised name is an alias of an existing tag' do let!(:existing_tag) { create(:tag) } diff --git a/backend/spec/models/tag_name_spec.rb b/backend/spec/models/tag_name_spec.rb index 8239d65..3d8ff38 100644 --- a/backend/spec/models/tag_name_spec.rb +++ b/backend/spec/models/tag_name_spec.rb @@ -22,16 +22,23 @@ RSpec.describe TagName, type: :model do expect(duplicate.errors.of_kind?(:name, :taken)).to be(true) end - it 'creates Nipponese factory names without requiring a tag' do + it 'creates Nipponese factory names with an owning tag' do name = create(:tag_name) expect(name.reload).to have_attributes( language_code: 'ja', script_code: 'Jpan', primary_flg: true, - auto_generated: false, tag_id: nil) + auto_generated: false, tag_id: name.tag.id) + end + + it 'rejects an ownerless name at model and database boundaries' do + name = build(:tag_name, tag: nil) + expect(name).to be_invalid + expect { name.save!(validate: false) }.to raise_error(ActiveRecord::NotNullViolation) end it 'defaults ordinary persisted human names to confirmed provenance' do - name = described_class.create!(name: 'ordinary_human_name', language_code: 'ja', + name = described_class.create!(tag: create(:tag, primary_tag_name: nil), + name: 'ordinary_human_name', language_code: 'ja', script_code: 'Jpan', primary_flg: true) expect(name.reload.auto_generated).to be(false) @@ -46,7 +53,7 @@ RSpec.describe TagName, type: :model do expect(name.tag).to eq(tag) expect(described_class.find_by!(tag_id: tag.id, language_code: 'ja', primary_flg: true)) .to eq(name) - expect(tag.reload.tag_name_id).to eq(name.id) + expect(tag.reload.tag_name('ja')&.id).to eq(name.id) end it 'creates a complete ordinary tag factory under the active Nipponese language' do @@ -96,19 +103,14 @@ RSpec.describe TagName, type: :model do end describe '.canonicalise' do - it 'leaves detached aliases unchanged' do - create(:tag_name, :alias, name: 'detached_alias') - expect(described_class.canonicalise(Locale.nipponese, ['detached_alias'])) - .to eq(['detached_alias']) - end - it 'does not resolve a detached alias through another detached primary' do - create(:tag_name, :alias, name: 'detached_alias') - create(:tag_name, name: 'unrelated_detached_primary') + it 'resolves an alias to its owning primary name' do + tag = create(:tag, primary_name: 'canonical') + create(:tag_name, :alias, tag:, name: 'alias') - expect(described_class.canonicalise(Locale.nipponese, ['detached_alias'])) - .to eq(['detached_alias']) + expect(described_class.canonicalise(Locale.nipponese, ['alias', 'unknown'])) + .to eq(['canonical', 'unknown']) end it 'resolves the primary name of the alias language on the same tag' do @@ -119,7 +121,7 @@ RSpec.describe TagName, type: :model do english_primary.update!(name: 'english_primary', auto_generated: false) english_alias = create(:tag_name, :alias, tag:, name: 'english_alias', language_code: 'en', script_code: 'Latn') - representative_id = tag.tag_name_id + representative_id = tag.tag_name('ja')&.id primary = described_class.find_by!(tag_id: english_alias.tag_id, language_code: english_alias.language_code, @@ -128,7 +130,7 @@ RSpec.describe TagName, type: :model do expect(primary).to eq(english_primary) expect(described_class.canonicalise(locale, [english_alias.name])) .to eq([english_primary.name]) - expect(tag.reload.tag_name_id).to eq(representative_id) + expect(tag.reload.tag_name('ja')&.id).to eq(representative_id) end it 'resolves only aliases in the requested language' do diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index 0cc9d8c..2bff7fa 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' RSpec.describe Tag, type: :model do + include WikiRecords describe '#has_description?' do let(:tag) { create(:tag) } @@ -14,12 +15,6 @@ RSpec.describe Tag, type: :model do expect(tag.has_description?(language_code: 'ja')).to be(false) end - it 'does not use a Wiki page as evidence of a description' do - user = create_member_user! - Wiki::Commit.create_legacy_content!(tag_name: tag.tag_name('ja'), body: 'legacy', - created_by_user: user) - expect(tag.has_description?(language_code: 'ja')).to be(false) - end it 'reuses preloaded descriptions without reading Wiki or querying again' do TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1) @@ -334,38 +329,7 @@ RSpec.describe Tag, type: :model do .where(tag_names: { name: 'atomic_english_name' })).to be_empty end - it 'keeps a detached requested name detached if generation fails' do - pending '日本語以外の Locale 有効化は別課題で実装する' - locale - detached = create(:tag_name, :alias, name: 'atomic_detached', - language_code: 'en', script_code: 'Latn') - original_tag_count = described_class.count - allow(TagName).to receive(:generate_name).and_raise('generation failed') - expect { - described_class.find_or_create_by_tag_name!( - locale, detached.name, category: :general) - }.to raise_error('generation failed') - - expect(described_class.count).to eq(original_tag_count) - expect(detached.reload).to have_attributes(tag_id: nil, primary_flg: false) - end - - it 'promotes a detached alias into the requested primary name' do - pending '日本語以外の Locale 有効化は別課題で実装する' - locale - detached = create(:tag_name, :alias, name: 'detached_english', - language_code: 'en', script_code: 'Latn') - - tag = described_class.find_or_create_by_tag_name!( - locale, detached.name, category: :general) - - expect(detached.reload).to have_attributes( - tag_id: tag.id, primary_flg: true, canonical_id: nil, - auto_generated: false) - expect(TagName.where(tag:, language_code: 'en', primary_flg: true)) - .to contain_exactly(detached) - end it 'creates separate tag identities for the same name in different languages' do pending '日本語以外の Locale 有効化は別課題で実装する' @@ -390,7 +354,7 @@ RSpec.describe Tag, type: :model do pending '日本語以外の Locale 有効化は別課題で実装する' locale tag = create(:tag, primary_name: '日本語代表名') - representative_id = tag.tag_name_id + representative_id = tag.tag_name('ja')&.id english = primary_tag_name_for(tag, 'en') english.update!(name: 'english_primary', auto_generated: false) @@ -398,7 +362,7 @@ RSpec.describe Tag, type: :model do locale, english.name, category: :general) expect(found).to eq(tag) - expect(tag.reload.tag_name_id).to eq(representative_id) + expect(tag.reload.tag_name('ja')&.id).to eq(representative_id) expect(english.reload.tag_id).to eq(tag.id) end @@ -430,22 +394,10 @@ RSpec.describe Tag, type: :model do expect(primary_tag_name_for(tag, 'ja').auto_generated).to be(false) end - it 'promotes a detached Nipponese alias into a human-confirmed primary' do - detached = create(:tag_name, :alias, name: 'detached_nipponese') - - tag = described_class.find_or_create_by_tag_name!( - Locale.nipponese, detached.name, category: :general) - - expect(detached.reload).to have_attributes( - tag_id: tag.id, primary_flg: true, canonical_id: nil, - auto_generated: false) - expect(TagName.where(tag:, language_code: 'ja', primary_flg: true)) - .to contain_exactly(detached) - end it 'reuses the owning tag for an alias without changing its category' do tag = create(:tag, category: :character) - representative_id = tag.tag_name_id + representative_id = tag.tag_name('ja')&.id alias_name = create(:tag_name, :alias, name: 'lookup_alias', tag:) expect(alias_name.reload).to have_attributes( tag_id: tag.id, @@ -459,7 +411,7 @@ RSpec.describe Tag, type: :model do expect(found).to eq(tag) expect(found.category).to eq('character') - expect(tag.reload.tag_name_id).to eq(representative_id) + expect(tag.reload.tag_name('ja')&.id).to eq(representative_id) end it 'reuses the owning tag through another alias' do @@ -683,7 +635,7 @@ RSpec.describe Tag, type: :model do let!(:wiki_page) do admin = create_admin_user! - Wiki::Commit.create_content!( + seed_description( tag_name: source_tag_name, body: 'source wiki body', created_by_user: admin, diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb index b891faa..0a2ea89 100644 --- a/backend/spec/requests/materials_spec.rb +++ b/backend/spec/requests/materials_spec.rb @@ -222,28 +222,27 @@ RSpec.describe 'Materials API', type: :request do expect(queries).to be_empty end - it 'reads the description even when the legacy revision has different content' do - page = seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'legacy body', + it 'reads the description even when the stored version has different content' do + page = seed_description(tag_name: tag.tag_name('ja'), body: 'legacy body', created_by_user: member_user) TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') .update!(body: 'description body') get_material_without_legacy_reads expect(json.fetch('wiki_page_body')).to eq('description body') - expect(page.current_revision.body).to eq('legacy body') + expect(current_description_version(page).body).to eq('legacy body') end it 'uses tag_id rather than description ID without a Wiki page or TagName' do - description = TagDescription.create!(id: tag.id + 1000, tag:, language_code: 'ja', + description = TagDescription.create!(tag:, language_code: 'ja', body: 'independent body', version_no: 1) - tag.tag_names.update_all(tag_id: nil) + tag.tag_names.delete_all get_material_without_legacy_reads - expect(description.id).not_to eq(tag.id) expect(json.fetch('wiki_page_body')).to eq('independent body') expect(json.fetch('tag').fetch('has_wiki')).to be(true) end - it 'returns nil when only legacy Wiki content exists' do - seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'legacy only', + it 'returns nil when only description history remains' do + seed_description(tag_name: tag.tag_name('ja'), body: 'legacy only', created_by_user: member_user) TagDescription.where(tag_id: tag.id).delete_all get_material_without_legacy_reads diff --git a/backend/spec/requests/tag_description_wiki_spec.rb b/backend/spec/requests/tag_description_wiki_spec.rb index 02b1184..5a52675 100644 --- a/backend/spec/requests/tag_description_wiki_spec.rb +++ b/backend/spec/requests/tag_description_wiki_spec.rb @@ -4,34 +4,21 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do include WikiRecords let!(:user) { create_member_user! } let!(:tag_name) { create(:tag_name, name: 'description_contract') } - let!(:page) do - seed_wiki_page( - tag_name:, body: 'old-revision', created_by_user: user) - end - let!(:revisions) do - first = page.current_revision - second = Wiki::Commit.legacy_content!(page:, body: 'old-second', created_user: user) - third = Wiki::Commit.legacy_content!(page:, body: 'old-third', created_user: user) - [first, second, third] - end - let!(:tag) { create(:tag, primary_tag_name: tag_name) } + let!(:tag) { tag_name.tag } let!(:versions) do - revisions.each_with_index.map do |revision, index| + ['A', 'A', 'new-version'].each_with_index.map do |body, index| TagDescriptionVersion.create!( - id: revision.id, tag_id: tag.id, language_code: 'ja', - version_no: index + 1, event_type: index.zero? ? 'create' : 'update', - body: index == 2 ? 'new-version' : 'A', - created_by_user_id: user.id, created_at: revision.created_at) + tag_id: tag.id, language_code: 'ja', version_no: index + 1, + event_type: index.zero? ? 'create' : 'update', body:, + created_by_user_id: user.id, created_at: Time.current) end end let!(:description) do TagDescription.create!( - id: page.id, tag:, language_code: 'ja', version_no: 3, body: 'new', + tag:, language_code: 'ja', version_no: 3, body: 'new', created_by_user_id: user.id, updated_by_user_id: user.id) end - before { page.update_columns(body: 'old') } - # Observe real SELECTs, including cached queries, only during the request. def without_legacy_reads queries = [] @@ -70,7 +57,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do it 'does not fall back to a revision absent from new history' do versions.first.destroy! without_legacy_reads do - get "/wiki/#{ description.id }", params: { version: revisions.first.id } + get "/wiki/#{ description.id }", params: { version: versions.first.id } end expect(response).to have_http_status(:not_found) end @@ -132,16 +119,6 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do 'pred' => versions.first.id, 'succ' => versions.last.id) end - it 'ignores changes to same-title legacy content and revision metadata' do - get "/wiki/title/#{ tag_name.name }" - original = json - page.update_columns(body: 'changed legacy body') - revisions.last.update_columns(message: 'legacy message', created_at: 1.day.ago) - revisions.last.wiki_lines.update_all(body: 'changed legacy revision') - get "/wiki/title/#{ tag_name.name }" - expect(json).to eq(original) - expect(json.fetch('body')).to eq('new') - end end describe 'exists' do @@ -155,28 +132,6 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do expect(response).to have_http_status(:no_content) end - context 'without a legacy page' do - before do - WikiVersion.where(wiki_page_id: page.id).delete_all - page.destroy! - end - - it 'recognises the description ID alone' do - get "/wiki/#{ description.id }/exists" - expect(response).to have_http_status(:no_content) - end - - it 'recognises the TagName alone' do - get "/wiki/title/#{ tag_name.name }/exists" - expect(response).to have_http_status(:no_content) - end - - it 'shows the description by title' do - get "/wiki/title/#{ tag_name.name }" - expect(response).to have_http_status(:ok) - expect(json).to include('body' => 'new', 'revision_id' => versions.last.id) - end - end end describe 'diff' do @@ -229,39 +184,8 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do 'title' => tag_name.name) end - it 'ignores legacy-only revisions and metadata changes' do - get '/wiki/changes', params: { id: description.id } - original = json - revisions.first.update_columns(message: 'legacy changed', created_at: 1.day.ago) - WikiRevision.create!( - wiki_page: page, created_user: user, kind: :content, - tree_sha256: Digest::SHA256.hexdigest('legacy only'), lines_count: 0) - get '/wiki/changes', params: { id: description.id } - expect(json).to eq(original) - expect(json.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id)) - end end - describe 'mixed changes' do - it 'excludes unmigrated history and migrated legacy-only revisions' do - legacy_page = seed_wiki_page( - tag_name: create(:tag_name, name: 'Help_mixed'), - body: 'help', created_by_user: user) - legacy_revision = legacy_page.current_revision - extra = WikiRevision.create!( - wiki_page: page, created_user: user, kind: :content, - tree_sha256: Digest::SHA256.hexdigest('legacy only'), lines_count: 0) - - get '/wiki/changes' - expect(response).to have_http_status(:ok) - migrated = json.select { |row| row.fetch('wiki_page').fetch('id') == description.id } - expect(migrated.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id)) - expect(json.map { |row| row.fetch('revision_id') }).not_to include(extra.id) - expect(json).not_to include(include( - 'revision_id' => legacy_revision.id, - 'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed'))) - end - end describe 'index' do [nil, 'description_contract'].each do |title| diff --git a/backend/spec/requests/tag_description_wiki_write_spec.rb b/backend/spec/requests/tag_description_wiki_write_spec.rb index 3b3af9d..0658d7c 100644 --- a/backend/spec/requests/tag_description_wiki_write_spec.rb +++ b/backend/spec/requests/tag_description_wiki_write_spec.rb @@ -1,280 +1,92 @@ require 'rails_helper' -RSpec.describe 'Tag description Wiki dual writes', type: :request do +RSpec.describe 'Tag description Wiki facade writes', type: :request do include WikiRecords let!(:user) { create_member_user! } - let!(:tag_name) { create(:tag_name, name: 'description_write') } - let(:tag) { create(:tag, primary_tag_name: tag_name) } + let!(:tag) { create(:tag, primary_name: 'description_write') } let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } def stored_state - [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion, - TagName, TagVersion].map do |model| + [Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion].map do |model| model.order(:id).map(&:attributes) end end - # Fail after a real INSERT/UPDATE, exercising rollback rather than a mock write. - def fail_save model - failure = Class.new(StandardError) - callback = ->(_record) { raise failure, 'injected persistence failure' } - model.set_callback(:save, :after, callback) - expect { yield }.to raise_error(failure) - ensure - model.skip_callback(:save, :after, callback) - end - - describe 'POST /wiki' do - before { tag } - - def create_description - post '/wiki', params: { title: tag_name.name, body: 'initial', message: 'create' }, - headers: headers - end - - it 'creates a description with matching Wiki content and histories' do - expect { create_description } - .to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - .and change(TagDescription, :count).by(1) - .and change(TagDescriptionVersion, :count).by(1) - expect(response).to have_http_status(:created) - description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') - expect(description).to have_attributes(body: 'initial', version_no: 1) - page = WikiPage.find_by!(tag_name:) - expect(page.body).to eq(description.body) - expect(page.current_revision.body).to eq(description.body) - expect(page.wiki_versions.sole.body).to eq(description.body) - expect(json.fetch('id')).to eq(description.id) - expect(TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja')) - .to have_attributes(body: 'initial', version_no: 1, created_by_user_id: user.id) - end - - it 'reuses an existing compatibility page and synchronises its histories' do - page = Wiki::Commit.create_legacy_content!( - tag_name:, body: 'legacy', created_by_user: user) - expect { create_description } - .to change(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - expect(response).to have_http_status(:created) - description = TagDescription.find_by!(tag_id: tag.id) - expect(page.reload.body).to eq(description.body) - expect(TagDescriptionVersion.find(page.current_revision.id).body).to eq(page.body) - end - - ['missing_description', 'orphan_description'].each do |title| - it "rejects the bare title #{ title } without writing any resource" do - create(:tag_name, name: title) if title == 'orphan_description' - before_state = stored_state - - post '/wiki', params: { title:, body: 'initial' }, headers: headers - - expect(response).to have_http_status(:unprocessable_entity) - expect(json).to include('type' => 'validation_error') - expect(stored_state).to eq(before_state) - end - end - - [false, true].each do |linked| - it "rejects namespaced creation with Tag present: #{ linked }" do - name = create(:tag_name, name: 'Help:frozen') - create(:tag, primary_tag_name: name) if linked - before_state = stored_state - post '/wiki', params: { title: name.name, body: 'legacy' }, headers: headers - expect(response.status).to be_between(400, 499) - expect(stored_state).to eq(before_state) - end - end - - [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| - it "rolls back new description creation when #{ model.name } persistence fails" do - before_state = stored_state - aggregate_failures do - fail_save(model) { create_description } - expect(stored_state).to eq(before_state) - end - end - end - end - - it 'creates missing compatibility data without writing an ID-colliding Help page' do - description = TagDescription.create!( - id: 500, tag:, language_code: 'ja', body: 'before', version_no: 1) - help = WikiPage.create!(id: description.id, - tag_name: create(:tag_name, name: 'Help:unrelated'), - body: 'unrelated', created_user: user, updated_user: user) - Wiki::Commit.legacy_content!(page: help, body: help.body, created_user: user) - before_help = help.attributes + it 'creates a native description and exposes its version ID as revision ID' do expect do - put "/wiki/#{ description.id }", params: { title: tag_name.name, body: 'after' }, - headers: headers - end.to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - expect(response).to have_http_status(:ok) - expect(description.reload).to have_attributes(id: 500, body: 'after') - expect(help.reload.attributes).to eq(before_help) - compatibility = WikiPage.find_by!(tag_name:) - expect(compatibility.body).to eq(description.body) - expect(compatibility.current_revision.body).to eq(description.body) + post '/wiki', params: { title: tag.name('ja'), body: 'initial' }, headers: headers + end.to change(TagDescription, :count).by(1) + .and change(TagDescriptionVersion, :count).by(1) + expect(response).to have_http_status(:created) + description = TagDescription.find(json.fetch('id')) + expect(description).to have_attributes(tag_id: tag.id, body: 'initial', version_no: 1) + get "/wiki/#{ description.id }" + expect(json.fetch('revision_id')).to eq(current_description_version(description).id) end - describe 'PUT /wiki/:id' do - let!(:page) do - tag - seed_wiki_page( - tag_name:, body: 'before', created_by_user: user) - end - let!(:first_revision) { page.current_revision } - + context 'with an existing description' do let!(:description) do - TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') + TagDescriptionOperation.create!(tag:, body: 'before', created_by_user: user) end + let!(:first_version) { current_description_version(description) } - def update_description body: 'after', title: tag_name.name, base: first_revision.id - put "/wiki/#{ page.id }", - params: { title:, body:, message: 'edit', base_revision_id: base }, + def update_description body: 'after', title: tag.name('ja'), base: first_version.id + put "/wiki/#{ description.id }", params: { title:, body:, base_revision_id: base }, headers: headers end - it 'increments the independent description sequence and shares revision identity' do - expect(page.reload.version_no).to eq(1) - expect(description.version_no).to eq(1) - expect { update_description } - .to change(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - .and change(TagDescriptionVersion, :count).by(1) - expect(response).to have_http_status(:ok) - - revision = page.reload.current_revision - version = TagDescriptionVersion.find(revision.id) - expect(description.reload).to have_attributes(body: 'after', version_no: 2) - expect(version).to have_attributes( - body: 'after', version_no: 2, event_type: 'update', created_by_user_id: user.id) - expect(page).to have_attributes(body: 'after', version_no: 2) - expect(revision.body).to eq(version.body) - expect(page.wiki_versions.order(:version_no).last.body).to eq(version.body) - end - - it 'uses description identity even when its Wiki TagName no longer owns the Tag' do - tag_name.update_columns(tag_id: nil) - - expect { update_description } - .to change(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - .and change(TagDescriptionVersion, :count).by(1) - + it 'records a body change without changing resource identity or old history' do + snapshot = first_version.attributes + expect { update_description }.to change(TagDescriptionVersion, :count).by(1) expect(response).to have_http_status(:ok) expect(description.reload).to have_attributes(body: 'after', version_no: 2) - expect(TagDescriptionVersion.find(page.reload.current_revision.id)) - .to have_attributes(tag_id: tag.id, body: 'after', version_no: 2) + expect(first_version.reload.attributes).to eq(snapshot) + expect(current_description_version(description)).to have_attributes( + body: 'after', event_type: 'update', created_by_user_id: user.id) end - it 'does not add body history for a title-only update' do - revision_count = WikiRevision.count - wiki_version_count = WikiVersion.count - description_version_count = TagDescriptionVersion.count - wiki_version_no = page.reload.version_no - description_version_no = description.version_no - current_revision_id = page.current_revision.id - page_state = page.attributes - description_state = description.attributes - - update_description(body: 'before', title: 'description_renamed') - - expect(response).to have_http_status(:ok) - expect(tag_name.reload.name).to eq('description_renamed') - expect(WikiRevision.count).to eq(revision_count) - expect(WikiVersion.count).to eq(wiki_version_count) - expect(TagDescriptionVersion.count).to eq(description_version_count) - expect(page.reload).to have_attributes(body: 'before', version_no: wiki_version_no) - expect(page.current_revision.id).to eq(current_revision_id) - expect(page.reload.attributes).to eq(page_state) - expect(description.reload).to have_attributes( - body: 'before', version_no: description_version_no) - expect(description.reload.attributes).to eq(description_state) - end - - it 'does not add body history for a TagName rename through tags' do - revision_count = WikiRevision.count - wiki_version_count = WikiVersion.count - description_version_count = TagDescriptionVersion.count - page_version_no = page.reload.version_no - current_revision_id = page.current_revision.id - - patch "/tags/#{ tag.id }", - params: { name: 'description_tag_renamed' }, - headers: headers - - expect(response).to have_http_status(:ok) - expect(tag_name.reload.name).to eq('description_tag_renamed') - expect(WikiRevision.count).to eq(revision_count) - expect(WikiVersion.count).to eq(wiki_version_count) - expect(TagDescriptionVersion.count).to eq(description_version_count) - expect(page.reload.version_no).to eq(page_version_no) - expect(page.current_revision.id).to eq(current_revision_id) - expect(description.reload).to have_attributes( - body: 'before', version_no: 1) - end - - it 'keeps both systems and the title unchanged on a stale revision conflict' do - update_description(body: 'current body', base: first_revision.id) - expect(response).to have_http_status(:ok) - - stale_revision_id = first_revision.id - before_state = stored_state - - update_description( - body: 'conflicting body', - title: 'conflicting_rename', - base: stale_revision_id) - - expect(response).to have_http_status(:conflict) - expect(stored_state).to eq(before_state) - expect(tag_name.reload.name).not_to eq('conflicting_rename') - end - - [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| - it "rolls back both systems when #{ model.name } persistence fails" do + ['before', "before\r\n\r\n"].each do |body| + it "does not write for normalised-equivalent body #{ body.inspect }" do before_state = stored_state - aggregate_failures do - fail_save(model) do - update_description(body: 'rollback body', title: 'rollback_rename') - end - expect(stored_state).to eq(before_state) - end + update_description(body:) + expect(response).to have_http_status(:ok) + expect(stored_state).to eq(before_state) end end - [['identical body', 'before'], - ['normalised-equivalent body', "before\n\n"]].each do |label, body| - it "does not write histories for #{ label }" do - before_state = stored_state - page_state = page.reload.attributes - description_state = description.reload.attributes - current_revision_id = page.current_revision.id - wiki_lines = WikiLine.order(:id).map(&:attributes) - revision_lines = WikiRevisionLine.order(:id).map(&:attributes) - page_version_no = page.version_no - description_version_no = description.version_no - - update_description(body:) + it 'records a title-only edit in Tag history without adding body history' do + snapshot = description.attributes + expect { update_description(body: 'before', title: 'renamed') } + .to change(TagVersion, :count).by(2) + .and change(TagDescriptionVersion, :count).by(0) + expect(response).to have_http_status(:ok) + expect(tag.reload.name('ja')).to eq('renamed') + expect(description.reload.attributes).to eq(snapshot) + end + ['conflicting body', 'after'].each do |body| + it "rejects a stale revision even for #{ body } without a partial rename" do + update_description expect(response).to have_http_status(:ok) - expect(WikiRevision.count).to eq(before_state[1].length) - expect(WikiVersion.count).to eq(before_state[2].length) - expect(TagDescriptionVersion.count).to eq(before_state[4].length) - expect(page.reload.version_no).to eq(page_version_no) - expect(description.reload.version_no).to eq(description_version_no) - expect(page.current_revision.id).to eq(current_revision_id) - expect(page.reload.attributes).to eq(page_state) - expect(description.reload.attributes).to eq(description_state) - expect(WikiLine.order(:id).map(&:attributes)).to eq(wiki_lines) - expect(WikiRevisionLine.order(:id).map(&:attributes)).to eq(revision_lines) + before_state = stored_state + update_description(body:, title: 'conflicting rename') + expect(response).to have_http_status(:conflict) + expect(stored_state).to eq(before_state) + end + end + + [TagDescription, TagDescriptionVersion].each do |model| + it "rolls back the rename and body after #{ model.name } persistence fails" do + before_state = stored_state + failure = Class.new(StandardError) + callback = ->(_record) { raise failure } + model.set_callback(:save, :after, callback) + begin + expect { update_description(title: 'rollback rename') }.to raise_error(failure) + expect(stored_state).to eq(before_state) + ensure + model.skip_callback(:save, :after, callback) + end end end end diff --git a/backend/spec/requests/tag_descriptions_spec.rb b/backend/spec/requests/tag_descriptions_spec.rb index 7901144..7fecf45 100644 --- a/backend/spec/requests/tag_descriptions_spec.rb +++ b/backend/spec/requests/tag_descriptions_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' RSpec.describe 'Tag description native API', type: :request do + include WikiRecords + include TagGroupRecords let!(:author) { create_member_user! } let(:editor) { create(:user, :member, name: 'description editor') } let(:tag_name) { create(:tag_name, name: 'native_description') } @@ -9,8 +11,7 @@ RSpec.describe 'Tag description native API', type: :request do let(:endpoint) { "/tags/#{ tag.id }/description" } def stored_state - [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion, - WikiLine, WikiRevisionLine, TagName, TagVersion].map do |model| + [TagDescription, TagDescriptionVersion, TagName, TagVersion].map do |model| model.order(:id).map(&:attributes) end end @@ -84,7 +85,7 @@ RSpec.describe 'Tag description native API', type: :request do describe 'native reads' do let!(:description) do TagDescription.create!( - id: tag.id + 1_000_000, tag:, language_code: 'ja', body: 'native current', + tag:, language_code: 'ja', body: 'native current', version_no: 1, created_by_user_id: author.id, updated_by_user_id: editor.id) end let!(:version) do @@ -95,15 +96,14 @@ RSpec.describe 'Tag description native API', type: :request do let(:name_endpoint) { "/tags/name/#{ tag_name.name }/description" } it 'reads the current resource by Tag ID without a WikiPage' do - expect(description.id).not_to eq(tag.id) - expect(WikiPage.exists?(id: description.id)).to be(false) without_legacy_reads { native_get(endpoint) } expect(response).to have_http_status(:ok) expect_current(description) end it 'does not interpret a compatibility handle as a Tag ID' do - other_tag = create(:tag, id: description.id, primary_name: 'handle_collision') + other_tag = create(:tag, primary_name: 'handle_collision') + description.update_columns(id: other_tag.id) other = TagDescription.create!( tag: other_tag, language_code: 'ja', body: 'other current', version_no: 1, created_by_user_id: author.id, updated_by_user_id: author.id) @@ -153,32 +153,6 @@ RSpec.describe 'Tag description native API', type: :request do expect_current(description) end - [:id, :name].each do |lookup| - it "ignores divergent legacy body and history for #{ lookup } lookup" do - page = WikiPage.create!( - id: description.id, tag_name:, body: 'old current', - created_user: author, updated_user: author) - revision = WikiRevision.create!( - wiki_page: page, created_user: author, kind: :content, - lines_count: 1, tree_sha256: Digest::SHA256.hexdigest('old revision')) - line = WikiLine.create!(body: 'old revision', sha256: revision.tree_sha256) - WikiRevisionLine.create!(wiki_revision: revision, wiki_line: line, position: 0) - WikiVersionRecorder.record!(page:, event_type: :create, created_by_user: author) - path = lookup == :id ? endpoint : name_endpoint - action = lookup == :id ? 'show' : 'show_by_title' - - without_legacy_reads { native_get(path, action:) } - expect(response).to have_http_status(:ok) - expect_current(description) - original = json - page.update_columns(body: 'changed old current') - line.update_columns(body: 'changed old revision') - revision.update_columns(message: 'changed old history', created_at: 1.day.ago) - without_legacy_reads { native_get(path, action:) } - expect(response).to have_http_status(:ok) - expect(json).to eq(original) - end - end end describe 'missing native resources' do @@ -206,44 +180,24 @@ RSpec.describe 'Tag description native API', type: :request do context 'without an existing description' do before { tag } - it 'creates a description with synchronised Wiki content and histories' do - unrelated = Wiki::Commit.create_legacy_content!( - tag_name: create(:tag_name, name: 'Help:unrelated_native_description'), - body: 'unrelated body', created_by_user: author) - unrelated_state = [unrelated.attributes, - unrelated.wiki_revisions.map(&:attributes), - unrelated.wiki_versions.map(&:attributes)] + + it 'creates native history that is immediately available through the facade' do expect { native_write(body: 'created natively') } .to change(TagDescription, :count).by(1) .and change(TagDescriptionVersion, :count).by(1) - .and change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) expect(response).to have_http_status(:created) description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') - page = WikiPage.find_by!(tag_name: tag.tag_name('ja')) - revision = page.current_revision - version = TagDescriptionVersion.find(revision.id) - expect(description).to have_attributes( - body: 'created natively', version_no: 1, - created_by_user_id: editor.id, updated_by_user_id: editor.id) - expect(version).to have_attributes( - tag_id: tag.id, language_code: 'ja', body: description.body, - version_no: 1, event_type: 'create', created_by_user_id: editor.id) - expect(page).to have_attributes(tag_name_id: tag_name.id, body: description.body) - expect(revision.body).to eq(version.body) - expect(page.wiki_versions.last.body).to eq(version.body) - unrelated.reload - expect([unrelated.attributes, unrelated.wiki_revisions.map(&:attributes), - unrelated.wiki_versions.map(&:attributes)]).to eq(unrelated_state) expect_current(description) - + version = current_description_version(description) + expect(version).to have_attributes( + body: 'created natively', version_no: 1, event_type: 'create', + created_by_user_id: editor.id) get "/wiki/#{ description.id }" expect(response).to have_http_status(:ok) expect(json).to include('body' => description.body, 'revision_id' => version.id) end - [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| + [TagDescription, TagDescriptionVersion].each do |model| it "rolls back native create when #{ model.name } persistence fails" do headers before_state = stored_state @@ -258,7 +212,7 @@ RSpec.describe 'Tag description native API', type: :request do context 'with an existing description' do let!(:page) do tag - Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: author) + seed_description(tag_name:, body: 'before', created_by_user: author) end let!(:description) do TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') @@ -268,22 +222,20 @@ RSpec.describe 'Tag description native API', type: :request do expect(page.reload.version_no).to eq(1) expect(description.version_no).to eq(1) expect { native_write(body: 'after') } - .to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - .and change(TagDescriptionVersion, :count).by(1) + .to change(TagDescriptionVersion, :count).by(1) expect(response).to have_http_status(:ok) expect(description.reload).to have_attributes( body: 'after', version_no: 2, created_by_user_id: author.id, updated_by_user_id: editor.id) expect(page.reload).to have_attributes(body: 'after', version_no: 2) - revision = page.current_revision + revision = current_description_version(page) version = TagDescriptionVersion.find(revision.id) expect(version).to have_attributes( tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update', body: 'after', created_by_user_id: editor.id) expect(revision.body).to eq(version.body) - expect(page.wiki_versions.order(:version_no).last.body).to eq(version.body) + expect(description_versions(page).order(:version_no).last.body).to eq(version.body) get "/wiki/#{ description.id }" expect(response).to have_http_status(:ok) expect(json).to include('body' => 'after', 'revision_id' => version.id) @@ -294,7 +246,7 @@ RSpec.describe 'Tag description native API', type: :request do before_state = stored_state page_state = page.reload.attributes description_state = description.reload.attributes - current_revision_id = page.current_revision.id + current_revision_id = current_description_version(page).id native_write(body:) @@ -302,11 +254,11 @@ RSpec.describe 'Tag description native API', type: :request do expect(stored_state).to eq(before_state) expect(page.reload.attributes).to eq(page_state) expect(description.reload.attributes).to eq(description_state) - expect(page.current_revision.id).to eq(current_revision_id) + expect(current_description_version(page).id).to eq(current_revision_id) end end - [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| + [TagDescription, TagDescriptionVersion].each do |model| it "rolls back native update when #{ model.name } persistence fails" do headers before_state = stored_state diff --git a/backend/spec/requests/tag_group_wiki_spec.rb b/backend/spec/requests/tag_group_wiki_spec.rb index b42805c..d24add8 100644 --- a/backend/spec/requests/tag_group_wiki_spec.rb +++ b/backend/spec/requests/tag_group_wiki_spec.rb @@ -7,21 +7,17 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do let!(:user) { create_member_user! } let!(:old_tag) { group_tag('旧タグ') } let!(:new_tag) { group_tag('新タグ') } - let!(:page) { legacy_group('読取り契約', '- 旧タグ') } - let!(:first_revision) { page.current_revision } + let!(:page) { create_markdown_group('読取り契約', '- 旧タグ') } + let!(:first_revision) { page.versions.sole } let!(:last_revision) do - Wiki::Commit.legacy_content!(page:, body: '- 新タグ', created_user: user) + parsed = TagGroupMarkdownParser.parse(body: '- 新タグ') + TagGroupOperation.update!( + group: page, name: page.name, actor: user, + **parsed.slice(:description, :members, :placements)) + page.versions.order(:version_no).last end - before do - I18n.locale = :ja - migrate_groups - page.update_columns(body: 'legacy stale current') - page.wiki_revisions.each do |revision| - revision.wiki_lines.update_all(body: 'legacy stale revision') - end - page.wiki_versions.update_all(body: 'legacy stale version') - end + before { I18n.locale = :ja } [:id, :title].each do |lookup| context "with #{ lookup } lookup" do @@ -71,36 +67,13 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do end it 'combines Tag Groups and Tag Descriptions without Help in the unfiltered list' do - help = seed_wiki_page( - tag_name: create(:tag_name, name: 'Help:混在'), body: 'help', created_by_user: user) TagDescriptionOperation.create!(tag: new_tag, body: 'tag description', created_by_user: user) get '/wiki' expect(response).to have_http_status(:ok) expect(json.map { _1.fetch('title') }).to include( 'タググループ:読取り契約', '新タグ') - expect(json.map { _1.fetch('title') }).not_to include(help.title) end - it 'excludes Help when its ID collides with a new group' do - help = seed_wiki_page( - tag_name: create(:tag_name, name: 'Help:ID重複'), body: 'help body', - created_by_user: user) - seed_group(name: '別 ID重複', id: help.id, description: 'group body') - - get '/wiki' - expect(response).to have_http_status(:ok) - expect(json.map { _1.fetch('title') }).to include('タググループ:別 ID重複') - expect(json.map { _1.fetch('title') }).not_to include(help.title) - - get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }" - expect(response).to have_http_status(:not_found) - - get '/wiki/changes' - expect(response).to have_http_status(:ok) - expect(json.map { _1.dig('wiki_page', 'title') }).to include( - 'タググループ:別 ID重複') - expect(json.map { _1.dig('wiki_page', 'title') }).not_to include(help.title) - end it 'finds groups by a partial prefix of their displayed title' do get '/wiki', params: { title: 'タググル' } @@ -112,8 +85,9 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do end it 'does not restore a stale namespaced Wiki page after a group rename' do - old_page = legacy_group('旧名称', '- 旧本文') - seed_group(name: '新名称', id: old_page.id, description: '正本') + group = create_markdown_group('旧名称', '正本') + TagGroupOperation.update!( + group:, name: '新名称', description: '正本', members: [], placements: [], actor: user) get group_title_path('旧名称') expect(response).to have_http_status(:not_found) @@ -125,12 +99,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do end it 'orders mixed resource history by timestamp rather than independent IDs' do - help = seed_wiki_page( - tag_name: create(:tag_name, name: 'Help:時系列'), body: 'help', - created_by_user: user) TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user) TagGroupVersion.update_all(created_at: 3.days.ago) - WikiRevision.where(wiki_page_id: help.id).update_all(created_at: 2.days.ago) TagDescriptionVersion.update_all(created_at: 1.day.ago) get '/wiki/changes' @@ -139,7 +109,6 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) } expect(timestamps).to eq(timestamps.sort.reverse) titles = json.map { _1.dig('wiki_page', 'title') } - expect(titles).not_to include(help.title) expect(titles.index('新タグ')).to be < titles.index('タググループ:読取り契約') end @@ -169,19 +138,6 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do 'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約')) end - it 'includes group history but excludes all legacy-only revisions' do - extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user) - help = seed_wiki_page( - tag_name: create(:tag_name, name: 'Help:変更一覧'), body: 'help', created_by_user: user) - get '/wiki/changes' - expect(response).to have_http_status(:ok) - rows = json.select { _1.fetch('wiki_page').fetch('title') == 'タググループ:読取り契約' } - expect(rows.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id]) - expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id) - expect(json).not_to include(include( - 'revision_id' => help.current_revision.id, - 'wiki_page' => include('title' => help.title))) - end [false, true].each do |explicit| it "diffs structured snapshots with #{ explicit ? 'explicit' : 'current' } target" do @@ -215,95 +171,5 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do expect(json.fetch('body').strip).to eq('独立した説明') end - context 'when Help, description and group share the same numeric ID' do - let(:colliding_help) do - seed_wiki_page( - tag_name: create(:tag_name, name: 'Help:三者ID衝突'), - body: 'help', created_by_user: user) - end - let(:colliding_group_id) do - seed_group(name: '三者ID衝突', id: colliding_help.id, description: 'group') - end - let(:colliding_description) do - TagDescription.create!( - id: colliding_help.id, tag: new_tag, language_code: 'ja', - body: 'description', version_no: 1) - end - let(:colliding_description_version) do - TagDescriptionVersion.create!( - tag_id: new_tag.id, language_code: 'ja', version_no: 1, - event_type: 'create', body: 'description', created_by_user_id: user.id, - created_at: Time.current) - end - before do - colliding_group_id - colliding_description - colliding_description_version - end - - it 'lists both new resources sharing a numeric ID without legacy Help' do - get '/wiki' - - expect(response).to have_http_status(:ok) - rows = json.select { _1.fetch('id') == colliding_help.id } - expect(rows.map { [_1.fetch('id'), _1.fetch('title')] }).to contain_exactly( - [colliding_description.id, '新タグ'], - [colliding_group_id, 'タググループ:三者ID衝突']) - end - - it 'lists each resource history without deduplicating the shared resource ID' do - get '/wiki/changes' - - expect(response).to have_http_status(:ok) - rows = json.select { _1.fetch('wiki_page').fetch('id') == colliding_help.id } - histories = rows.map do |row| - [row.fetch('revision_id'), row.fetch('wiki_page').fetch('title')] - end - expect(histories).to contain_exactly( - [colliding_description_version.id, '新タグ'], - [group_version(colliding_group_id, 1).fetch('id'), 'タググループ:三者ID衝突']) - end - - it 'still excludes legacy copies of an actually migrated group and its history' do - extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user) - - get '/wiki' - - expect(response).to have_http_status(:ok) - rows = json.select { _1.fetch('id') == page.id } - expect(rows.map { _1.fetch('title') }).to eq(['タググループ:読取り契約']) - - get '/wiki/changes' - - expect(response).to have_http_status(:ok) - rows = json.select { _1.fetch('wiki_page').fetch('id') == page.id } - expect(rows.map { _1.fetch('revision_id') }) - .to contain_exactly(first_revision.id, last_revision.id) - expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id) - expect(rows.map { _1.fetch('wiki_page').fetch('title') }) - .to all(eq('タググループ:読取り契約')) - end - end - - it 'resolves new resources by title while excluding colliding legacy Help' do - help = seed_wiki_page( - tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user) - seed_group(name: 'ID衝突', id: help.id, description: 'group') - TagDescription.create!( - id: help.id, tag: new_tag, language_code: 'ja', body: 'description', version_no: 1) - TagDescriptionVersion.create!( - tag_id: new_tag.id, language_code: 'ja', version_no: 1, - event_type: 'create', body: 'description', created_at: Time.current) - { 'タググループ:ID衝突' => 'group', '新タグ' => 'description' } - .each do |title, body| - get "/wiki/title/#{ CGI.escapeURIComponent(title) }" - expect(response).to have_http_status(:ok) - expect(json.fetch('body').strip).to eq(body) - end - without_group_legacy_reads do - get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }" - end - expect(response).to have_http_status(:not_found) - end end diff --git a/backend/spec/requests/tag_group_wiki_write_spec.rb b/backend/spec/requests/tag_group_wiki_write_spec.rb index 7498e9f..846537f 100644 --- a/backend/spec/requests/tag_group_wiki_write_spec.rb +++ b/backend/spec/requests/tag_group_wiki_write_spec.rb @@ -33,11 +33,10 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do end end - it 'creates the group, snapshot and matching compatibility histories' do + it 'creates the group, snapshot and native version history' do expect { create_group } - .to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) + .to change(TagGroup, :count).by(1) + .and change(TagGroupVersion, :count).by(1) expect(response).to have_http_status(:created) group = group_rows('tag_groups', name: '新規').sole expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規') @@ -45,10 +44,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do version = group_version(group.fetch('id'), 1) expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id) expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id) - page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:新規' }) - expect(page.body).to eq('- 洗澡鹿') - expect(page.current_revision.body).to eq(page.body) - expect(page.wiki_versions.sole.body).to eq(page.body) + get group_title_path('新規') + expect(response).to have_http_status(:ok) + expect(json.fetch('body')).to eq('- 洗澡鹿') end it 'keeps repeated placements but only one membership and one snapshot member' do @@ -108,10 +106,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do end context 'updating a migrated group' do - let!(:page) { legacy_group('更新', '- 洗澡鹿') } - let!(:old_revision) { page.current_revision } + let!(:page) { create_markdown_group('更新', '- 洗澡鹿') } + let!(:old_revision) { page.versions.order(:version_no).last } - before { migrate_groups } def update_group body: '- 伊地知ニジカ', title: 'タググループ:更新', request_headers: headers put "/wiki/#{ page.id }", params: { title:, body: }, headers: request_headers @@ -119,9 +116,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do [1, 3].each do |repetitions| it "keeps identical Markdown unchanged after #{ repetitions } Wiki PUTs" do - body = page.body + body = group_markdown(page) travel_to(1.hour.from_now) do - expect_noop_group_update(group_id: page.id, page:) do + expect_noop_group_update(group_id: page.id) do repetitions.times do update_group(body:) expect(response).to have_http_status(:ok) @@ -134,13 +131,13 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do it 'keeps identical Markdown unchanged with a stale base_revision_id' do update_group expect(response).to have_http_status(:ok) - body = page.reload.body - expect(page.current_revision.id).not_to eq(old_revision.id) + body = group_markdown(page) + expect(page.versions.order(:version_no).last.id).not_to eq(old_revision.id) travel_to(1.hour.from_now) do - expect_noop_group_update(group_id: page.id, page:) do + expect_noop_group_update(group_id: page.id) do put "/wiki/#{ page.id }", - params: { title: page.title, body:, base_revision_id: old_revision.id }, + params: { title: "タググループ:#{ page.name }", body:, base_revision_id: old_revision.id }, headers: headers end end @@ -148,9 +145,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do it 'keeps the original editor on identical Markdown from another member' do editor = create(:user, :member) - body = page.body + body = group_markdown(page) travel_to(1.hour.from_now) do - expect_noop_group_update(group_id: page.id, page:) do + expect_noop_group_update(group_id: page.id) do update_group(body:, request_headers: { 'X-Transfer-Code' => editor.inheritance_code }) end end @@ -168,11 +165,11 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do page.id, number: 2, members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }], placements: changed_placements) - body = page.reload.body + body = group_markdown(page) expect(body).to eq('- 伊地知ニジカ') travel_to(1.hour.from_now) do - expect_noop_group_update(group_id: page.id, page:) { update_group(body:) } + expect_noop_group_update(group_id: page.id) { update_group(body:) } end end @@ -187,16 +184,13 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do expect(group_version(page.id, 1)).to eq(old) expect(group_version(page.id, 2)).to include( 'event_type' => 'update', 'created_by_user_id' => user.id) - expect(page.reload.body.strip).to eq('- 伊地知ニジカ') - expect(page.wiki_revisions.count).to eq(2) - expect(page.wiki_versions.count).to eq(2) - expect(page.current_revision.body).to eq(page.body) - expect(page.wiki_versions.order(:version_no).last.body).to eq(page.body) + expect(group_markdown(page).strip).to eq('- 伊地知ニジカ') + expect(page.versions.count).to eq(2) end it 'accepts the current base_revision_id used by the existing frontend' do put "/wiki/#{ page.id }", - params: { title: page.title, body: '- 伊地知ニジカ', + params: { title: "タググループ:#{ page.name }", body: '- 伊地知ニジカ', base_revision_id: old_revision.id }, headers: headers expect(response).to have_http_status(:ok) expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(2) @@ -207,12 +201,12 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do expect(response).to have_http_status(:ok) put "/wiki/#{ page.id }", - params: { title: page.title, body: '- 洗澡鹿', + params: { title: "タググループ:#{ page.name }", body: '- 洗澡鹿', base_revision_id: old_revision.id }, headers: headers expect(response).to have_http_status(:ok) expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(3) - expect(page.reload.body.strip).to eq('- 洗澡鹿') + expect(group_markdown(page).strip).to eq('- 洗澡鹿') end it 'records the editor and update time' do @@ -257,9 +251,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do let!(:page) do next unless operation == :update - legacy = legacy_group('更新', '- 洗澡鹿') - seed_group(name: '更新', id: legacy.id, members:, placements:) - legacy + create_markdown_group('更新', '- 洗澡鹿') end let!(:child_id) { seed_group(name: '子グループ', description: '子') } @@ -296,7 +288,6 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do end tables = TagGroupRecords::GROUP_TABLES - tables += ['wiki_pages', 'wiki_revisions', 'wiki_versions'] tables.each do |table| it "rolls back all writes after a real #{ table } write fails" do expect_atomic_group_write(table) { write_group(operation, headers) } diff --git a/backend/spec/requests/tag_group_write_integrity_spec.rb b/backend/spec/requests/tag_group_write_integrity_spec.rb index 98c2e5d..24e5d99 100644 --- a/backend/spec/requests/tag_group_write_integrity_spec.rb +++ b/backend/spec/requests/tag_group_write_integrity_spec.rb @@ -24,11 +24,6 @@ RSpec.describe 'Tag group write integrity', type: :request do put "/wiki/#{ id }", params: { title:, body:, base_revision_id: base }, headers: headers end - def create_help body - seed_wiki_page( - tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user) - end - def expect_no_writes before_state = group_state yield @@ -37,66 +32,28 @@ RSpec.describe 'Tag group write integrity', type: :request do end context 'resource identity' do - it 'rejects removing the prefix from a migrated group atomically' do - page = legacy_group('更新', '- 洗澡鹿') - seed_group(name: '更新', id: page.id, members:, placements:) - - expect_no_writes do - put_wiki(page.id, title: '更新', base: page.current_revision.id) - end - end it 'rejects an unprefixed update to an independent group' do id = seed_group(name: '独立', members:, placements:) expect_no_writes { put_wiki(id, title: '独立') } end - it 'does not revive legacy writes when the compatibility title is stale' do - page = legacy_group('更新', '- 洗澡鹿') - seed_group(name: '更新', id: page.id, members:, placements:) - page.tag_name.update!(name: '古い別名') - expect_no_writes { put_wiki(page.id, title: '古い別名') } - end - - it 'renames the group and its compatibility page through the operation' do - page = legacy_group('更新', '- 洗澡鹿') - seed_group(name: '更新', id: page.id, members:, placements:) + it 'renames the group and its facade title through the operation' do + page = create_markdown_group('更新', '- 洗澡鹿') put_wiki(page.id, title: 'タググループ:改名') expect(response).to have_http_status(:ok) expect(TagGroup.find(page.id)).to have_attributes(name: '改名', version_no: 2) - expect(page.reload.title).to eq('タググループ:改名') - expect(page.body).to eq('- 伊地知ニジカ') + expect("タググループ:#{ page.reload.name }").to eq('タググループ:改名') + expect(group_markdown(page)).to eq('- 伊地知ニジカ') end - it 'rejects a standalone Help update despite a colliding group ID' do - page = create_help('Help body') - id = seed_group(name: '別グループ', id: page.id, members:, placements:) - group_before = TagGroup.find(id).attributes - version_before = group_version(id, 1) - expect_no_writes { put_wiki(id, title: 'Help:改名', body: 'Help after') } - - expect(page.reload.body).to eq('Help body') - expect(page.title).to eq('Help:既存') - expect(TagGroup.find(id).attributes).to eq(group_before) - expect(group_version(id, 1)).to eq(version_before) - end - - it 'rejects an ambiguous unprefixed rename when Help and group IDs collide' do - page = create_help('Help body') - seed_group(name: '更新', id: page.id, members:, placements:) - - expect_no_writes { put_wiki(page.id, title: '更新') } - expect_no_writes do - put_wiki(page.id, title: '更新', base: page.current_revision.id) - end - end it 'keeps a description update separate from a colliding group' do - page = Wiki::Commit.create_content!( + page = seed_description( tag_name: tag.tag_name('ja'), body: 'description before', created_by_user: user) description = TagDescription.find_by!(tag_id: tag.id) id = seed_group(name: '更新', id: description.id, members:, placements:) @@ -110,106 +67,22 @@ RSpec.describe 'Tag group write integrity', type: :request do expect(TagGroup.find(id).attributes).to eq(group_before) end - it 'does not route a colliding Help write into an unrelated description' do - page = create_help('Help before') - id = seed_group(name: '更新', id: page.id, members:, placements:) - description = TagDescription.create!( - id:, tag:, language_code: 'ja', version_no: 1, body: 'description') + end - expect_no_writes { put_wiki(id, title: 'Help:既存', body: 'Help after') } - expect(page.reload.body).to eq('Help before') - expect(description.reload.body).to eq('description') - expect(TagGroup.find(id).version_no).to eq(1) - put_wiki(id, title: '洗澡鹿', body: 'description after') - expect(response).to have_http_status(:ok) - expect(description.reload.body).to eq('description after') - put_wiki(id, title: 'タググループ:更新') - expect(response).to have_http_status(:ok) - expect(TagGroup.find(id).version_no).to eq(2) - expect(page.reload.body).to eq('Help before') - expect(description.reload.body).to eq('description after') + it 'rolls back a facade create when its saved structure cannot be rendered' do + allow(TagGroupMarkdownRenderer).to receive(:render) + .and_raise(TagGroupMarkdownRenderer::UnsupportedStructure, 'unsupported') + expect_no_writes do + post '/wiki', params: { title: 'タググループ:新規', body: '- 洗澡鹿' }, headers: headers end end - context 'compatibility synchronisation' do - it 'synchronises a native rename without modifying an ID-colliding Help page' do - help = create_help('Help before') - id = seed_group(name: '更新', id: help.id, members:, placements:) - help_before = help.attributes - - expect do - put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json - end.to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - - expect(response).to have_http_status(:ok) - expect(TagGroup.find(id).name).to eq('改名') - expect(help.reload.attributes).to eq(help_before) - end - - ['wiki_lines', 'wiki_revision_lines'].each do |table| - it "rolls back native and compatibility state on #{ table } failure" do - expect_atomic_group_write(table) do - post '/tag_groups', params: native_payload, headers:, as: :json - end - end - end - - it 'rolls back a renderer failure instead of treating it as unsupported structure' do - allow(TagGroupMarkdownRenderer).to receive(:render).and_raise('renderer failure') - before_state = group_state - - expect do - post '/tag_groups', params: native_payload, headers:, as: :json - end.to raise_error(RuntimeError, 'renderer failure') - - expect(group_state).to eq(before_state) - end - - it 'preserves unplaced membership and reports the skipped sync with its version' do - page = legacy_group('更新', '- 洗澡鹿') - id = seed_group(name: '更新', id: page.id, members:, placements:) - wiki_before = group_state.slice(*TagGroupRecords::WRITE_TABLES.drop(5)) - warnings = [] - allow(Rails.logger).to receive(:warn) { warnings << _1 } - - put "/tag_groups/#{ id }", - params: native_payload(member_rows: members + [{ 'tag_id' => other_tag.id }]), - headers:, as: :json - - expect(response).to have_http_status(:ok) - expect(json.fetch('members').map { _1.fetch('tag').fetch('id') }) - .to contain_exactly(tag.id, other_tag.id) - expect(group_version(id, 2).fetch('members')).to include( - { 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }) - expect(group_state.slice(*TagGroupRecords::WRITE_TABLES.drop(5))).to eq(wiki_before) - payload = JSON.parse(warnings.grep(/tag_group_wiki_sync_skipped/).sole.split(' ', 2).last) - expect(payload).to include( - 'status' => 'unsupported_structure', 'tag_group_id' => id, - 'version_no' => 2, 'user_id' => user.id, - 'reason' => '掲載位置のない membership は Markdown にできません.') - end - - it 'preserves a list Section description and records why no Wiki was created' do - nodes = [{ 'tag_id' => tag.id, 'children' => [ - { 'section_name' => '区分', 'description' => '構造化の説明', 'children' => [] }] }] - warnings = [] - allow(Rails.logger).to receive(:warn) { warnings << _1 } - before_count = WikiPage.count - - post '/tag_groups', params: native_payload(nodes:), headers:, as: :json - - expect(response).to have_http_status(:created) - id = json.fetch('id') - expect(group_version(id, 1).fetch('placements')).to eq(nodes) - expect(json.fetch('placements').sole.fetch('children').sole) - .to include('description' => '構造化の説明') - expect(WikiPage.count).to eq(before_count) - expect(warnings.grep(/tag_group_wiki_sync_skipped/).sole) - .to include('リスト内 Section の説明文は Markdown にできません.') - end + it 'rolls back a facade update when its saved structure cannot be rendered' do + id = seed_group(name: '更新', members:, placements:) + allow(TagGroupMarkdownRenderer).to receive(:render) + .and_raise(TagGroupMarkdownRenderer::UnsupportedStructure, 'unsupported') + expect_no_writes { put_wiki(id, title: 'タググループ:改名') } end context 'identifier Markdown' do @@ -293,10 +166,10 @@ RSpec.describe 'Tag group write integrity', type: :request do expect(group_version(id, 2)).to include('description' => '説明を追加', 'members' => members) end - it 'rolls back membership removal and placement edits on a late sync failure' do + it 'rolls back membership removal and placement edits on a version persistence failure' do new_members = [{ 'tag_id' => tag.id }] new_nodes = [{ 'tag_id' => tag.id, 'children' => [] }] - expect_atomic_group_write('wiki_versions') do + expect_atomic_group_write('tag_group_versions') do put "/tag_groups/#{ id }", params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json end diff --git a/backend/spec/requests/tag_groups_spec.rb b/backend/spec/requests/tag_groups_spec.rb index d910dfb..ebca4c7 100644 --- a/backend/spec/requests/tag_groups_spec.rb +++ b/backend/spec/requests/tag_groups_spec.rb @@ -234,8 +234,7 @@ RSpec.describe 'Tag group native API', type: :request do context 'compatibility writes for representable structures' do let(:group_id) do - page = legacy_group('専用API', '説明') - seed_group(name: '専用API', id: page.id, description: '説明') + seed_group(name: '専用API', description: '説明') end let(:members) { [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }] } let(:snapshot_members) do @@ -252,10 +251,9 @@ RSpec.describe 'Tag group native API', type: :request do group_id, number: 2, description: '全体の説明', placements:, members: snapshot_members + [ { 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }]) - page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:専用API' }) travel_to(1.hour.from_now) do - expect_noop_group_update(group_id:, page:) do + expect_noop_group_update(group_id:) do native_write(:update, unplaced_payload) end end @@ -267,20 +265,18 @@ RSpec.describe 'Tag group native API', type: :request do members: [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }], placements: } end let(:additional_tag) { group_tag('追加対象タグ') } - let(:compatibility_page) do - WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:構造化' }) - end before do native_write(:update, synchronised_payload) expect(response).to have_http_status(:ok) - expect(compatibility_page.body).to include('- 掲載タグ', '- タググループ:子') + expect(group_markdown(TagGroup.find(group_id))) + .to include('- 掲載タグ', '- タググループ:子') end [1, 3].each do |repetitions| it "keeps identical native content unchanged after #{ repetitions } PUTs" do travel_to(1.hour.from_now) do - expect_noop_group_update(group_id:, page: compatibility_page) do + expect_noop_group_update(group_id:) do repetitions.times do native_write(:update, synchronised_payload) expect(response).to have_http_status(:ok) @@ -292,7 +288,7 @@ RSpec.describe 'Tag group native API', type: :request do it 'treats membership order alone as a no-op' do travel_to(1.hour.from_now) do - expect_noop_group_update(group_id:, page: compatibility_page) do + expect_noop_group_update(group_id:) do native_write(:update, synchronised_payload.merge( members: synchronised_payload.fetch(:members).reverse)) end @@ -302,7 +298,7 @@ RSpec.describe 'Tag group native API', type: :request do it 'keeps the original editor when another member submits identical content' do editor = create(:user, :member) travel_to(1.hour.from_now) do - expect_noop_group_update(group_id:, page: compatibility_page) do + expect_noop_group_update(group_id:) do put "/tag_groups/#{ group_id }", params: synchronised_payload, headers: { 'X-Transfer-Code' => editor.inheritance_code }, as: :json end @@ -313,7 +309,7 @@ RSpec.describe 'Tag group native API', type: :request do put "/wiki/#{ group_id }", params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers expect(response).to have_http_status(:ok) - expect(compatibility_page.reload.body).to eq('- 未掲載タグ') + expect(group_markdown(TagGroup.find(group_id))).to eq('- 未掲載タグ') same_content = { name: '構造化', description: nil, members: [{ 'tag_id' => unplaced.id }], placements: [{ 'tag_id' => unplaced.id, 'children' => [] }] } @@ -323,7 +319,7 @@ RSpec.describe 'Tag group native API', type: :request do placements: same_content.fetch(:placements)) travel_to(1.hour.from_now) do - expect_noop_group_update(group_id:, page: compatibility_page) do + expect_noop_group_update(group_id:) do native_write(:update, same_content) end end @@ -332,7 +328,7 @@ RSpec.describe 'Tag group native API', type: :request do ['name', 'description', 'membership addition', 'membership removal', 'Section name', 'Section description', 'placement order', 'placement hierarchy'] .each do |change| - it "records a real #{ change } change in both histories" do + it "records a real #{ change } change in native history" do changed = synchronised_payload.deep_dup section = changed.fetch(:placements).first children = section.fetch('children') @@ -356,11 +352,6 @@ RSpec.describe 'Tag group native API', type: :request do old_versions = group_rows('tag_group_versions', tag_group_id: group_id) group = TagGroup.find(group_id) number = group.version_no - page = compatibility_page - old_revision_id = page.current_revision.id - old_wiki_version_id = page.wiki_versions.order(version_no: :desc).first.id - revision_count = page.wiki_revisions.count - version_count = page.wiki_versions.count travel_to(1.hour.from_now) { native_write(:update, changed) } expect(response).to have_http_status(:ok) @@ -379,23 +370,17 @@ RSpec.describe 'Tag group native API', type: :request do versions = group_rows('tag_group_versions', tag_group_id: group_id) expect(versions.size).to eq(old_versions.size + 1) expect(versions.select { _1.fetch('version_no') <= number }).to eq(old_versions) - expect(page.wiki_revisions.count).to eq(revision_count + 1) - expect(page.wiki_versions.count).to eq(version_count + 1) - expect(page.reload.current_revision.id).not_to eq(old_revision_id) - expect(page.wiki_versions.order(version_no: :desc).first.id) - .not_to eq(old_wiki_version_id) get group_title_path(changed.fetch(:name)) expect(response).to have_http_status(:ok) - expect(json.fetch('body')).to eq(page.body) + expect(json.fetch('body')).to eq(group_markdown(group)) end end end - it 'creates native state and its compatibility histories' do + it 'creates native state and its native history' do expect { native_write(:create) } - .to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) + .to change(TagGroup, :count).by(1) + .and change(TagGroupVersion, :count).by(1) expect(response).to have_http_status(:created) expect_group_snapshot( json.fetch('id'), number: 1, members: snapshot_members, @@ -410,18 +395,13 @@ RSpec.describe 'Tag group native API', type: :request do number = operation == :create ? 1 : 2 expect_group_snapshot( id, number:, members: snapshot_members, placements:, description: '全体の説明') - page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:構造化' }) - expect(page.body).to include('全体の説明', '# 区分', '- 掲載タグ', '- タググループ:子') - expect(page.current_revision.body).to eq(page.body) - expect(page.wiki_versions.order(:version_no).last.body).to eq(page.body) - expect(page.wiki_revisions.count).to eq(number) - expect(page.wiki_versions.count).to eq(number) + expect(TagGroup.find(id).versions.count).to eq(number) get group_title_path('構造化') expect(response).to have_http_status(:ok) - expect(json.fetch('body')).to eq(page.body) + expect(json.fetch('body')).to include('全体の説明', '# 区分', '- 掲載タグ') end - (TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions']) + TagGroupRecords::GROUP_TABLES .each do |table| it "rolls back native #{ operation } after #{ table } writes" do expect_atomic_group_write(table) { native_write(operation) } @@ -429,12 +409,12 @@ RSpec.describe 'Tag group native API', type: :request do end end - it 'assigns the next group ID from the existing maximum' do + it 'assigns the next group ID above the existing maximum' do child_id expected_id = [TagGroup.maximum(:id).to_i, TagDescription.maximum(:id).to_i].max + 1 native_write(:create) expect(response).to have_http_status(:created) - expect(json.fetch('id')).to eq(expected_id) + expect(json.fetch('id')).to be >= expected_id end TagGroupRecords::GROUP_TABLES.each do |table| diff --git a/backend/spec/requests/tag_resource_ids_spec.rb b/backend/spec/requests/tag_resource_ids_spec.rb index 7c324cc..e06e96b 100644 --- a/backend/spec/requests/tag_resource_ids_spec.rb +++ b/backend/spec/requests/tag_resource_ids_spec.rb @@ -1,101 +1,59 @@ require 'rails_helper' RSpec.describe 'Tag resource ID allocation', type: :request do - include TagGroupRecords include WikiRecords - let!(:user) { create_member_user! } let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } - def seed_description id - tag = create(:tag, primary_name: "description_id_#{ id }") - TagDescription.create!(id:, tag:, language_code: 'ja', body: 'existing', version_no: 1) - end - - def create_resource resource - if resource == :description - tag = create(:tag, primary_name: 'new_description') - post '/wiki', params: { title: tag.name('ja'), body: 'new description' }, headers: headers - else - post '/tag_groups', params: { - name: 'new_group', description: 'new group', members: [], placements: [] }, - headers:, as: :json - end + def create_description name + tag = create(:tag, primary_name: name) + post '/wiki', params: { title: tag.name('ja'), body: 'description' }, headers: headers expect(response).to have_http_status(:created) + TagDescription.find(json.fetch('id')) end - [:description, :group].each do |resource| - it "keeps #{ resource } independent of the Tag and ExternalTag ID space" do - create(:tag, id: 100) - create(:external_tag, id: 200) - seed_description(1) - seed_group(name: 'shared_space', id: 2) + def create_group name + post '/tag_groups', params: { name:, members: [], placements: [] }, headers:, as: :json + expect(response).to have_http_status(:created) + TagGroup.find(json.fetch('id')) + end - create_resource(resource) - - expect(json.fetch('id')).to eq(3) - end - - it "uses the other resource's larger maximum when allocating #{ resource }" do - seed_description(resource == :description ? 1 : 20) - seed_group(name: 'other_space', id: resource == :group ? 1 : 20) - create_resource(resource) - expect(json.fetch('id')).to eq(21) - end - - it "starts #{ resource } at 1 even after previous allocated rows were removed" do - expect(TagDescription.count).to eq(0) - expect(TagGroup.count).to eq(0) - if resource == :description - seed_description(100).destroy! - else - id = seed_group(name: 'removed', id: 100) - TagGroup.find(id).destroy! - end - create_resource(resource) - expect(json.fetch('id')).to eq(1) - end - - it "allocates #{ resource } above both maxima, preserving legacy overlaps and gaps" do - [1, 2, 5].each { seed_description(_1) } - [1, 3, 8].each { seed_group(name: "group_#{ _1 }", id: _1) } - descriptions = TagDescription.order(:id).map(&:attributes) - groups = TagGroup.order(:id).map(&:attributes) - - create_resource(resource) - - expect(json.fetch('id')).to eq(9) - expect(TagDescription.where(id: [1, 2, 5]).order(:id).map(&:attributes)).to eq(descriptions) - expect(TagGroup.where(id: [1, 3, 8]).order(:id).map(&:attributes)).to eq(groups) - expect(TagDescription.exists?(id: 3)).to be(false) - expect(TagGroup.exists?(id: 2)).to be(false) + it 'shares resource and revision IDs while keeping independent version numbers' do + description = create_description('first') + group = create_group('second') + other = create_description('third') + expect([description.id, group.id, other.id]).to eq( + [description.id, group.id, other.id].sort.uniq) + versions = [current_description_version(description), group.versions.sole, + current_description_version(other)] + expect(versions.map(&:id)).to eq(versions.map(&:id).sort.uniq) + expect(versions.map(&:version_no)).to eq([1, 1, 1]) + [description, group, other].zip(versions).each do |resource, version| + get "/wiki/#{ resource.id }" + expect(response).to have_http_status(:ok) + expect(json.fetch('revision_id')).to eq(version.id) end end - it 'preserves group ID and unrelated rows on update' do - id = seed_group(name: 'existing', id: 5, description: 'before') - other_id = seed_group(name: 'other', id: 8, description: 'other') - other = TagGroup.find(other_id).attributes - put "/tag_groups/#{ id }", params: { - name: 'renamed', description: 'after', members: [], placements: [] }, headers:, as: :json + it 'does not reuse a deleted description ID for a group or vice versa' do + description = create_description('deleted') + description.destroy! + group = create_group('deleted group') + expect(group.id).to be > description.id + group.destroy! + expect(create_description('replacement').id).to be > group.id + end + + it 'preserves resource and old revision identity on an update' do + description = create_description('stable') + original = current_description_version(description) + snapshot = original.attributes + put "/wiki/#{ description.id }", params: { + title: 'stable', body: 'after', base_revision_id: original.id }, headers: headers expect(response).to have_http_status(:ok) - expect(json.fetch('id')).to eq(id) - expect(TagGroup.find(id).description).to eq('after') - expect(TagGroup.find(other_id).attributes).to eq(other) - expect(TagGroup.count).to eq(2) - end - - it 'preserves description identity on a compatibility update' do - tag = create(:tag, primary_name: 'existing_description') - page = seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user) - description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') - id = description.id - put "/wiki/#{ id }", params: { - title: tag.name('ja'), body: 'after', base_revision_id: page.current_revision.id }, - headers: headers - expect(response).to have_http_status(:ok) - expect(description.reload.id).to eq(id) - expect(description.body).to eq('after') - expect(TagDescription.count).to eq(1) + expect(json.fetch('id')).to eq(description.id) + expect(description.reload).to have_attributes(body: 'after', version_no: 2) + expect(original.reload.attributes).to eq(snapshot) + expect(current_description_version(description).id).to be > original.id end end diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb index f105773..7165a0f 100644 --- a/backend/spec/requests/tag_versions_spec.rb +++ b/backend/spec/requests/tag_versions_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' RSpec.describe 'TagVersions API', type: :request do + include WikiRecords let(:member) { create(:user, :member, name: 'version member') } let!(:tag) { create(:tag, primary_name: 'tag_versions_target', category: :general) } @@ -240,18 +241,18 @@ RSpec.describe 'TagVersions API', type: :request do wiki_tag = create(:tag, primary_tag_name: wiki_tag_name, category: :general) wiki_page = - Wiki::Commit.create_content!( + seed_description( tag_name: wiki_tag_name, body: 'before', created_by_user: member, message: 'init') - Wiki::Commit.content!( - page: wiki_page, + TagDescriptionOperation.update!( + tag: wiki_page.tag, body: 'after', - created_user: member, + updated_by_user: member, message: 'edit', - base_revision_id: wiki_page.current_revision.id) + base_revision_id: current_description_version(wiki_page).id) get '/tags/versions', params: { id: wiki_tag.id } diff --git a/backend/spec/requests/tag_wiki_history_integrity_spec.rb b/backend/spec/requests/tag_wiki_history_integrity_spec.rb index 665eee2..bd8e7a9 100644 --- a/backend/spec/requests/tag_wiki_history_integrity_spec.rb +++ b/backend/spec/requests/tag_wiki_history_integrity_spec.rb @@ -14,7 +14,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do end def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user - seed_wiki_page( + seed_description( tag_name: primary_tag_name_for(tag, 'ja'), body:, created_by_user: user, @@ -29,7 +29,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do it 'renames the current Wiki resource without adding body history' do tag = create_tag!(name: 'patch_tag_wiki_before') wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') - wiki_history = wiki_page.wiki_versions.map(&:attributes) + wiki_history = description_versions(wiki_page).map(&:attributes) expect { patch "/tags/#{ tag.id }", params: { @@ -37,7 +37,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) expect(response).to have_http_status(:ok) @@ -45,16 +45,16 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do wiki_page.reload expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_wiki_after') - expect(wiki_page.title).to eq('patch_tag_wiki_after') + expect(wiki_page.tag.name('ja')).to eq('patch_tag_wiki_after') - expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) + expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history) end it 'does not record wiki_version when only category changes' do tag = create_tag!(name: 'patch_tag_category_only') wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') - before_wiki_versions = wiki_page.wiki_versions.count + before_wiki_versions = description_versions(wiki_page).count expect { patch "/tags/#{ tag.id }", params: { @@ -70,7 +70,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_category_only') expect(tag.category).to eq('meme') - expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions) + expect(description_versions(wiki_page).count).to eq(before_wiki_versions) end end @@ -78,7 +78,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do it 'renames the current Wiki resource without adding body history' do tag = create_tag!(name: 'put_tag_wiki_before') wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') - wiki_history = wiki_page.wiki_versions.map(&:attributes) + wiki_history = description_versions(wiki_page).map(&:attributes) expect { put "/tags/#{ tag.id }", params: { @@ -90,7 +90,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) expect(response).to have_http_status(:ok) @@ -98,16 +98,16 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do wiki_page.reload expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_wiki_after') - expect(wiki_page.title).to eq('put_tag_wiki_after') + expect(wiki_page.tag.name('ja')).to eq('put_tag_wiki_after') - expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) + expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history) end it 'does not record wiki_version when only category changes' do tag = create_tag!(name: 'put_tag_category_only') wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') - before_wiki_versions = wiki_page.wiki_versions.count + before_wiki_versions = description_versions(wiki_page).count expect { put "/tags/#{ tag.id }", params: { @@ -127,14 +127,14 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_category_only') expect(tag.category).to eq('meme') - expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions) + expect(description_versions(wiki_page).count).to eq(before_wiki_versions) end it 'does not record wiki_version when only aliases change' do tag = create_tag!(name: 'put_tag_alias_only') wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') - before_wiki_versions = wiki_page.wiki_versions.count + before_wiki_versions = description_versions(wiki_page).count expect { put "/tags/#{ tag.id }", params: { @@ -148,7 +148,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do .to change(TagVersion, :count).by(2) expect(response).to have_http_status(:ok) - expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_versions) + expect(description_versions(wiki_page).count).to eq(before_wiki_versions) end end end diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index d30f5e4..897aed4 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -2,6 +2,7 @@ require 'cgi' require 'rails_helper' RSpec.describe 'Tags API', type: :request do + include WikiRecords let!(:tn) { create(:tag_name, name: 'spec_tag') } let!(:tag) { create(:tag, primary_tag_name: tn, category: :general) } let!(:alias_tn) { create(:tag_name, :alias, name: 'unko', tag:) } @@ -745,13 +746,13 @@ RSpec.describe 'Tags API', type: :request do it 'PATCH の名称変更で Wiki の本文履歴を追加しない' do wiki_page = - Wiki::Commit.create_content!( + seed_description( tag_name: primary_tag_name_for(tag, 'ja'), body: 'wiki body before', created_by_user: member_user, message: 'init') - wiki_history = wiki_page.wiki_versions.map(&:attributes) + wiki_history = description_versions(wiki_page).map(&:attributes) expect { patch "/tags/#{ tag.id }", params: { @@ -759,23 +760,23 @@ RSpec.describe 'Tags API', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) expect(response).to have_http_status(:ok) - expect(wiki_page.reload.title).to eq('patch_wiki_renamed_tag') - expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) + expect(wiki_page.reload.tag.name('ja')).to eq('patch_wiki_renamed_tag') + expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history) end it 'tag の category だけを変更しても wiki version は作成しない' do wiki_page = - Wiki::Commit.create_content!( + seed_description( tag_name: primary_tag_name_for(tag, 'ja'), body: 'wiki body before', created_by_user: member_user, message: 'init') - before_wiki_version_count = wiki_page.reload.wiki_versions.count + before_wiki_version_count = description_versions(wiki_page).count expect { patch "/tags/#{ tag.id }", params: { @@ -784,7 +785,7 @@ RSpec.describe 'Tags API', type: :request do }.to change(TagVersion, :count).by(2) expect(response).to have_http_status(:ok) - expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count) + expect(description_versions(wiki_page).count).to eq(before_wiki_version_count) end it 'full update で旧名 alias が残った tag を PATCH で旧名へ戻せる' do @@ -815,7 +816,7 @@ RSpec.describe 'Tags API', type: :request do it '別 tag の正規名には変更できない' do wiki_page = - Wiki::Commit.create_content!( + seed_description( tag_name: primary_tag_name_for(tag, 'ja'), body: 'patch collision wiki', created_by_user: member_user, @@ -831,7 +832,7 @@ RSpec.describe 'Tags API', type: :request do expect(primary_tag_name_for(tag.reload, 'ja').name).to eq('spec_tag') expect(TagName.where(tag_id: tag.id, language_code: 'ja', primary_flg: false).pluck(:name)) .to contain_exactly('unko') - expect(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja')) + expect(wiki_page.reload.tag.tag_name('ja')).to eq(primary_tag_name_for(tag, 'ja')) end it 'system tag の name は変更できない' do @@ -1209,7 +1210,7 @@ RSpec.describe 'Tags API', type: :request do expect(old_name_alias).to have_attributes( tag_id: tag.id, language_code: 'ja', primary_flg: false) - expect(alias_tn.reload.tag_id).to be_nil + expect(TagName.exists?(alias_tn.id)).to be(false) expect(TagName.where(tag_id: tag.parents.select(:id), language_code: 'ja', primary_flg: true).pluck(:name)) @@ -1394,20 +1395,20 @@ RSpec.describe 'Tags API', type: :request do it 'Wiki を持つ Tag の名称変更で本文履歴を追加しない' do wiki_page = - Wiki::Commit.create_content!( + seed_description( tag_name: primary_tag_name_for(tag, 'ja'), body: 'wiki body before', created_by_user: member_user, message: 'init') - Wiki::Commit.content!( - page: wiki_page, + TagDescriptionOperation.update!( + tag: wiki_page.tag, body: 'wiki body before', - created_user: member_user, + updated_by_user: member_user, message: 'init' ) - wiki_history = wiki_page.wiki_versions.map(&:attributes) + wiki_history = description_versions(wiki_page).map(&:attributes) expect { put "/tags/#{ tag.id }", params: { @@ -1419,22 +1420,22 @@ RSpec.describe 'Tags API', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) expect(response).to have_http_status(:ok) - expect(wiki_page.reload.title).to eq('put_wiki_version_tag') - expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) + expect(wiki_page.reload.tag.name('ja')).to eq('put_wiki_version_tag') + expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history) end it 'wiki を持つ tag を旧 alias へ戻しても wiki を新しい正本名へ移す' do wiki_page = - Wiki::Commit.create_content!( + seed_description( tag_name: primary_tag_name_for(tag, 'ja'), body: 'wiki body before', created_by_user: member_user, message: 'init') - wiki_history = wiki_page.wiki_versions.map(&:attributes) + wiki_history = description_versions(wiki_page).map(&:attributes) expect { put "/tags/#{ tag.id }", params: { @@ -1454,20 +1455,19 @@ RSpec.describe 'Tags API', type: :request do } } .to change(TagVersion, :count).by(3) - .and change(WikiVersion, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) expect(response).to have_http_status(:ok) tag.reload - expect(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja')) - expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b').wiki_page) - .to be_nil + expect(wiki_page.reload.tag.tag_name('ja')).to eq(primary_tag_name_for(tag, 'ja')) + expect(wiki_page.reload.tag_id).to eq(tag.id) expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b')) .to have_attributes(tag_id: tag.id, primary_flg: false) - expect(wiki_page.title).to eq('spec_tag') - expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) + expect(wiki_page.tag.name('ja')).to eq('spec_tag') + expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history) end it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do @@ -1520,7 +1520,7 @@ RSpec.describe 'Tags API', type: :request do name: 'put_alias_collision_name', tag: old_owner) wiki_page = - Wiki::Commit.create_content!( + seed_description( tag_name: primary_tag_name_for(tag, 'ja'), body: 'put collision wiki', created_by_user: member_user, @@ -1554,7 +1554,7 @@ RSpec.describe 'Tags API', type: :request do expect(old_owner_versions.last.event_type).to eq('update') expect(version_alias_names(old_owner_versions.last, 'ja')) .not_to include('put_alias_collision_name') - expect(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja')) + expect(wiki_page.reload.tag.tag_name('ja')).to eq(primary_tag_name_for(tag, 'ja')) end it 'parent_tags に指定すると循環する tag は 422 にする' do @@ -1579,13 +1579,13 @@ RSpec.describe 'Tags API', type: :request do it 'Tag の名称変更で Wiki の本文履歴を追加しない' do wiki_page = - Wiki::Commit.create_content!( + seed_description( tag_name: primary_tag_name_for(tag, 'ja'), body: 'wiki body before', created_by_user: member_user, message: 'init') - wiki_history = wiki_page.wiki_versions.map(&:attributes) + wiki_history = description_versions(wiki_page).map(&:attributes) expect { put "/tags/#{ tag.id }", params: { @@ -1597,12 +1597,12 @@ RSpec.describe 'Tags API', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) expect(response).to have_http_status(:ok) - expect(wiki_page.reload.title).to eq('put_wiki_renamed_tag') - expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) + expect(wiki_page.reload.tag.name('ja')).to eq('put_wiki_renamed_tag') + expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history) end end end diff --git a/backend/spec/requests/wiki_conflict_spec.rb b/backend/spec/requests/wiki_conflict_spec.rb index 8aebaa6..5da7988 100644 --- a/backend/spec/requests/wiki_conflict_spec.rb +++ b/backend/spec/requests/wiki_conflict_spec.rb @@ -10,18 +10,18 @@ RSpec.describe 'Wiki conflict handling', type: :request do it 'returns 409 when base_revision_id is stale' do page = - seed_wiki_page( + seed_description( tag_name: create(:tag, primary_name: 'wiki_conflict_request').tag_name('ja'), body: 'first', created_by_user: user, message: 'init') - stale_id = page.current_revision.id + stale_id = current_description_version(page).id - Wiki::Commit.content!( - page:, + TagDescriptionOperation.update!( + tag: page.tag, body: 'second', - created_user: user, + updated_by_user: user, message: 'other edit', base_revision_id: stale_id) @@ -36,6 +36,6 @@ RSpec.describe 'Wiki conflict handling', type: :request do page.reload expect(page.body).to eq('second') - expect(page.current_revision.message).to eq('other edit') + expect(current_description_version(page)).to have_attributes(body: 'second', version_no: 2) end end diff --git a/backend/spec/requests/wiki_creation_freeze_spec.rb b/backend/spec/requests/wiki_creation_freeze_spec.rb index 96e4af4..a51b8ac 100644 --- a/backend/spec/requests/wiki_creation_freeze_spec.rb +++ b/backend/spec/requests/wiki_creation_freeze_spec.rb @@ -7,27 +7,11 @@ RSpec.describe 'Wiki creation freeze', type: :request do let!(:user) { create_member_user! } let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } - it 'creates missing compatibility history when updating an independent group' do - id = seed_group(name: 'independent', description: 'before') - expect do - put "/tag_groups/#{ id }", params: { - name: 'independent', description: 'after', members: [], placements: [] }, - headers:, as: :json - end.to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - expect(response).to have_http_status(:ok) - expect(TagGroup.find(id).description).to eq('after') - page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:independent' }) - expect(page.body).to include('after') - expect(page.current_revision.body).to eq(page.body) - expect(page.wiki_versions.sole.body).to eq(page.body) - end context 'existing description write protection' do let!(:tag) { create(:tag, primary_name: 'protected_description') } let!(:page) do - seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user) + seed_description(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user) end [nil, :guest, :banned].each do |role| diff --git a/backend/spec/requests/wiki_history_integrity_spec.rb b/backend/spec/requests/wiki_history_integrity_spec.rb deleted file mode 100644 index 60fda7f..0000000 --- a/backend/spec/requests/wiki_history_integrity_spec.rb +++ /dev/null @@ -1,170 +0,0 @@ -require 'cgi' -require 'rails_helper' - -RSpec.describe 'Wiki history integrity', type: :request do - include WikiRecords - let!(:user) { create_member_user! } - - def auth_headers user - { 'X-Transfer-Code' => user.inheritance_code } - end - - def create_wiki_page title:, body: 'body', message: 'init', user: self.user - seed_wiki_page( - tag_name: create(:tag, primary_name: title).tag_name('ja'), - body:, - created_by_user: user, - message:) - end - - describe 'POST /wiki' do - it 'rejects frozen creation without partial page or history' do - expect do - post '/wiki', params: { title: 'Help:wiki_history_create_atomic', body: 'initial' }, - headers: auth_headers(user) - end.to change(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(0) - .and change(WikiVersion, :count).by(0) - expect(response.status).to be_between(400, 499) - end - - it 'returns 422 and creates nothing when normalised body is blank' do - expect { - post '/wiki', - params: { - title: 'Help:wiki_history_blank_body', - body: "\r\n\r\n", - message: 'blank', - }, - headers: auth_headers(user) - } - .not_to change(WikiPage, :count) - - expect(response).to have_http_status(:unprocessable_entity) - expect(WikiPage.joins(:tag_name) - .where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist - end - - it 'rejects frozen creation without modifying an existing page or history' do - create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first') - - expect do - post '/wiki', - params: { title: 'Help:wiki_history_duplicate_title', - body: 'second', message: 'duplicate' }, - headers: auth_headers(user) - end.to change(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(0) - .and change(WikiVersion, :count).by(0) - - expect(response.status).to be_between(400, 499) - expect(WikiPage.joins(:tag_name) - .where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1) - end - end - - describe 'PUT /wiki/:id' do - it 'updates body and records wiki_revision and wiki_version' do - page = create_wiki_page(title: 'wiki_history_update_body', body: 'before') - current_id = page.current_revision.id - - expect { - put "/wiki/#{ page.id }", - params: { - title: 'wiki_history_update_body', - body: 'after', - message: 'edit body', - base_revision_id: current_id, - }, - headers: auth_headers(user) - } - .to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - - expect(response).to have_http_status(:ok) - - page.reload - version = page.wiki_versions.order(:version_no).last - - expect(page.title).to eq('wiki_history_update_body') - expect(page.body).to eq('after') - - expect(version).to have_attributes( - event_type: 'update', - title: 'wiki_history_update_body', - body: 'after', - reason: 'edit body', - created_by_user_id: user.id - ) - end - - it 'renames title and records wiki_version with new title' do - page = create_wiki_page(title: 'wiki_history_rename_before', body: 'before') - current_id = page.current_revision.id - - expect { - put "/wiki/#{ page.id }", - params: { - title: 'wiki_history_rename_after', - body: 'after', - message: 'rename', - base_revision_id: current_id, - }, - headers: auth_headers(user) - } - .to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - - expect(response).to have_http_status(:ok) - - page.reload - version = page.wiki_versions.order(:version_no).last - - expect(page.title).to eq('wiki_history_rename_after') - expect(page.body).to eq('after') - - expect(version).to have_attributes( - event_type: 'update', - title: 'wiki_history_rename_after', - body: 'after', - reason: 'rename', - created_by_user_id: user.id - ) - end - - it 'does not change title, body, revision, or version on stale base_revision_id' do - page = create_wiki_page(title: 'wiki_history_conflict_page', body: 'first') - stale_id = page.current_revision.id - - Wiki::Commit.content!( - page:, - body: 'second', - created_user: user, - message: 'other edit', - base_revision_id: stale_id) - - page.reload - current_title = page.title - current_body = page.body - revision_count = page.wiki_revisions.count - version_count = page.wiki_versions.count - - put "/wiki/#{ page.id }", - params: { - title: 'wiki_history_conflict_renamed', - body: 'third', - message: 'stale edit', - base_revision_id: stale_id, - }, - headers: auth_headers(user) - - expect(response).to have_http_status(:conflict) - - page.reload - expect(page.title).to eq(current_title) - expect(page.body).to eq(current_body) - expect(page.wiki_revisions.count).to eq(revision_count) - expect(page.wiki_versions.count).to eq(version_count) - end - end -end diff --git a/backend/spec/requests/wiki_index_order_spec.rb b/backend/spec/requests/wiki_index_order_spec.rb index c5ce7e9..9d9dd24 100644 --- a/backend/spec/requests/wiki_index_order_spec.rb +++ b/backend/spec/requests/wiki_index_order_spec.rb @@ -28,15 +28,15 @@ RSpec.describe 'Wiki resource ordering', type: :request do it 'breaks timestamp ties by resource kind and ID while retaining legacy overlaps' do time = Time.utc(2026, 1, 1) tag = create(:tag, primary_name: 'tied') - TagDescription.create!(id: 51, tag:, language_code: 'ja', body: 'current', + description = TagDescription.create!(tag:, language_code: 'ja', body: 'current', version_no: 1, updated_at: time) - group = TagGroup.find(seed_group(name: 'tied', id: 51)) + group = TagGroup.find(seed_group(name: 'tied', id: description.id)) group.update_columns(updated_at: time) 2.times do get '/wiki', params: { title: 'tied' } expect(json.map { [_1.fetch('resource_type'), _1.fetch('id')] }) - .to eq([['tag_description', 51], ['tag_group', 51]]) + .to eq([['tag_description', description.id], ['tag_group', description.id]]) end end end diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index 377f851..e3d7735 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -19,7 +19,7 @@ RSpec.describe 'Wiki API', type: :request do deprecated_at: Time.zone.local(2026, 6, 1)) end let!(:page) do - seed_wiki_page( + seed_description( tag_name: tn, body: 'init', created_by_user: user, @@ -113,9 +113,9 @@ RSpec.describe 'Wiki API', type: :request do it "rejects frozen creation of #{ title } without history writes" do expect do post endpoint, params: { title:, body: 'initial' }, headers: auth_headers(member) - end.to change(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(0) - .and change(WikiVersion, :count).by(0) + end.to change(TagDescription, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) + expect(response.status).to be_between(400, 499) end end @@ -132,7 +132,7 @@ RSpec.describe 'Wiki API', type: :request do let!(:test_tag_name) { create(:tag, primary_name: 'TestPage').tag_name('ja') } let!(:page) do - seed_wiki_page( + seed_description( tag_name: test_tag_name, body: "a\nb", created_by_user: member, @@ -166,7 +166,7 @@ RSpec.describe 'Wiki API', type: :request do context 'when success' do it 'creates new revision and returns 200' do - current_id = page.wiki_revisions.maximum(:id) + current_id = description_versions(page).maximum(:id) expect do put "/wiki/#{page.id}", @@ -174,26 +174,22 @@ RSpec.describe 'Wiki API', type: :request do base_revision_id: current_id }, headers: auth_headers(member) end - .to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) + .to change(TagDescriptionVersion, :count).by(1) - version = page.wiki_versions.order(:version_no).last + + version = description_versions(page).order(:version_no).last expect(version).to have_attributes( event_type: 'update', - title: 'TestPage', body: "x\ny", - created_by_user_id: member.id - ) + created_by_user_id: member.id) expect(response).to have_http_status(:ok) page.reload - rev = page.current_revision - expect(rev).to be_content - expect(rev.message).to eq('edit') + rev = current_description_version(page) expect(page.body).to eq("x\ny") - expect(rev.base_revision_id).to eq(current_id) + expect(rev.version_no).to eq(2) end it 'wiki body だけを変更しても tag version は作成しない' do @@ -206,13 +202,13 @@ RSpec.describe 'Wiki API', type: :request do created_by_user: member) linked_page = - seed_wiki_page( + seed_description( tag_name: linked_tag_name, body: 'before', created_by_user: member, message: 'init') - current_id = linked_page.current_revision.id + current_id = current_description_version(linked_page).id before_count = linked_tag.reload.tag_versions.count expect { @@ -225,8 +221,8 @@ RSpec.describe 'Wiki API', type: :request do }, headers: auth_headers(member) } - .to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) + .to change(TagDescriptionVersion, :count).by(1) + expect(response).to have_http_status(:ok) expect(linked_tag.reload.tag_versions.count).to eq(before_count) @@ -236,10 +232,10 @@ RSpec.describe 'Wiki API', type: :request do context 'when conflict' do it 'returns 409 when base_revision_id mismatches' do # 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める - Wiki::Commit.content!(page: page, body: "zzz", created_user: member, message: 'other edit') + TagDescriptionOperation.update!(tag: page.tag, body: "zzz", updated_by_user: member, message: 'other edit') page.reload - stale_id = page.wiki_revisions.order(:id).first.id # わざと古い id + stale_id = description_versions(page).order(:id).first.id # わざと古い id put "/wiki/#{page.id}", params: { title: 'TestPage', body: 'x', base_revision_id: stale_id }, headers: auth_headers(member) @@ -281,13 +277,13 @@ RSpec.describe 'Wiki API', type: :request do describe 'GET /wiki/search' do before do search_tag = create(:tag, primary_name: 'spec_wiki_title_2') - seed_wiki_page( + seed_description( tag_name: search_tag.tag_name('ja'), body: 'search body 2', created_by_user: user, message: 'init') - seed_wiki_page( + seed_description( tag_name: create(:tag_name, name: 'unrelated_title'), body: 'unrelated body', created_by_user: user, @@ -318,13 +314,13 @@ RSpec.describe 'Wiki API', type: :request do describe 'GET /wiki/changes' do let!(:rev1) do - Wiki::Commit.content!(page: page, body: "a\nb", created_user: user, message: 'r1') - page.current_revision + TagDescriptionOperation.update!(tag: page.tag, body: "a\nb", updated_by_user: user, message: 'r1') + current_description_version(page) end let!(:rev2) do - Wiki::Commit.content!(page: page, body: "a\nc", created_user: user, message: 'r2') - page.current_revision + TagDescriptionOperation.update!(tag: page.tag, body: "a\nc", updated_by_user: user, message: 'r2') + current_description_version(page) end it 'returns latest revisions (optionally filtered by page id)' do @@ -359,11 +355,8 @@ RSpec.describe 'Wiki API', type: :request do # 別ページを作って revision 無し tn2 = create(:tag_name, name: 'spec_no_rev') # 異常データ: revision 無し WikiPage を直接作る - p2 = WikiPage.create!( - tag_name: tn2, - body: 'init', - created_user: user, - updated_user: user) + p2 = TagDescription.create!( + tag: tn2.tag, language_code: 'ja', body: 'init', version_no: 1) get "/wiki/changes?id=#{p2.id}" expect(response).to have_http_status(:ok) @@ -399,13 +392,13 @@ RSpec.describe 'Wiki API', type: :request do describe 'GET /wiki/:id/diff' do let!(:rev_a) do - Wiki::Commit.content!(page: page, body: "a\nb\nc", created_user: user, message: 'A') - page.current_revision + TagDescriptionOperation.update!(tag: page.tag, body: "a\nb\nc", updated_by_user: user, message: 'A') + current_description_version(page) end let!(:rev_b) do - Wiki::Commit.content!(page: page, body: "a\nx\nc", created_user: user, message: 'B') - page.current_revision + TagDescriptionOperation.update!(tag: page.tag, body: "a\nx\nc", updated_by_user: user, message: 'B') + current_description_version(page) end it 'returns diff json between revisions' do @@ -431,44 +424,23 @@ RSpec.describe 'Wiki API', type: :request do expect(response).to have_http_status(:ok) expect(json['older_revision_id']).to eq(rev_a.id) - expect(json['newer_revision_id']).to eq(page.current_revision.id) + expect(json['newer_revision_id']).to eq(current_description_version(page).id) end end - describe 'Wiki::Commit.redirect!' do - it 'raises because redirect revisions are deprecated' do - target_tag_name = create(:tag_name, name: 'redirect_deprecated_target') - target = - seed_wiki_page( - tag_name: target_tag_name, - body: 'target', - created_by_user: user, - message: 'init') - - expect { - Wiki::Commit.redirect!( - page: page, - redirect_page: target, - created_user: user, - message: 'redirect', - base_revision_id: page.current_revision.id - ) - }.to raise_error(RuntimeError, '廃止しました.') - end - end it 'wiki title を変更すると対応する tag の version を作成する' do linked_tag_name = create(:tag_name, name: 'wiki_linked_tag_for_version') linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general) linked_page = - seed_wiki_page( + seed_description( tag_name: linked_tag_name, body: 'before', created_by_user: user, message: 'init') - current_id = linked_page.current_revision.id + current_id = current_description_version(linked_page).id expect { put "/wiki/#{ linked_page.id }", @@ -480,8 +452,8 @@ RSpec.describe 'Wiki API', type: :request do }, headers: auth_headers(user) } - .to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) + .to change(TagDescriptionVersion, :count).by(1) + .and change { linked_tag.reload.tag_versions.count }.by(2) expect(response).to have_http_status(:ok) diff --git a/backend/spec/requests/wiki_title_collision_spec.rb b/backend/spec/requests/wiki_title_collision_spec.rb index d61122f..f6f3011 100644 --- a/backend/spec/requests/wiki_title_collision_spec.rb +++ b/backend/spec/requests/wiki_title_collision_spec.rb @@ -9,7 +9,7 @@ RSpec.describe 'Wiki title collision', type: :request do end def create_wiki_page title:, body: - seed_wiki_page( + seed_description( tag_name: create(:tag_name, name: title), body:, created_by_user: user, @@ -20,9 +20,9 @@ RSpec.describe 'Wiki title collision', type: :request do source = create_wiki_page(title: 'wiki_collision_source', body: 'source body') create_wiki_page(title: 'wiki_collision_target', body: 'target body') - source_revision_count = source.wiki_revisions.count - source_version_count = source.wiki_versions.count - old_title = source.title + source_revision_count = description_versions(source).count + source_version_count = description_versions(source).count + old_title = source.tag.reload.name('ja') old_body = source.body put "/wiki/#{ source.id }", @@ -30,7 +30,7 @@ RSpec.describe 'Wiki title collision', type: :request do title: 'wiki_collision_target', body: 'new body', message: 'rename collision', - base_revision_id: source.current_revision.id, + base_revision_id: current_description_version(source).id, }, headers: auth_headers(user) @@ -38,10 +38,10 @@ RSpec.describe 'Wiki title collision', type: :request do source.reload - expect(source.title).to eq(old_title) + expect(source.tag.reload.name('ja')).to eq(old_title) expect(source.body).to eq(old_body) - expect(source.wiki_revisions.count).to eq(source_revision_count) - expect(source.wiki_versions.count).to eq(source_version_count) + expect(description_versions(source).count).to eq(source_revision_count) + expect(description_versions(source).count).to eq(source_version_count) end it 'rejects frozen creation with an existing title without adding history' do @@ -52,9 +52,8 @@ RSpec.describe 'Wiki title collision', type: :request do params: { title: 'Help:wiki_collision_create', body: 'new body', message: 'duplicate create' }, headers: auth_headers(user) - end.to change(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(0) - .and change(WikiVersion, :count).by(0) + end.to change(TagDescription, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) expect(response.status).to be_between(400, 499) end diff --git a/backend/spec/requests/wiki_unmigrated_contract_spec.rb b/backend/spec/requests/wiki_unmigrated_contract_spec.rb index e60c8e0..869be12 100644 --- a/backend/spec/requests/wiki_unmigrated_contract_spec.rb +++ b/backend/spec/requests/wiki_unmigrated_contract_spec.rb @@ -1,78 +1,25 @@ require 'rails_helper' -RSpec.describe 'Unmigrated Wiki compatibility', type: :request do - include WikiRecords - let!(:user) { create_member_user! } - let!(:tag_name) { create(:tag_name, name: 'Help_contract') } - let!(:page) do - seed_wiki_page(tag_name:, body: 'before', created_by_user: user) - end - let!(:first_revision) { page.current_revision } - let!(:last_revision) do - Wiki::Commit.legacy_content!(page:, body: 'after', created_user: user, - message: 'legacy edit', base_revision_id: first_revision.id) - end - - before { expect(TagDescription.exists?(id: page.id)).to be(false) } - - it 'rejects standalone updates even after a Tag is linked to the legacy page' do - create(:tag, primary_tag_name: tag_name) - original = page.reload.attributes - - expect do - put "/wiki/#{ page.id }", - params: { title: 'legacy_tag_renamed', body: 'changed legacy body', - message: 'legacy rename', base_revision_id: last_revision.id }, - headers: { 'X-Transfer-Code' => user.inheritance_code } - end.to change(WikiRevision, :count).by(0) - .and change(WikiVersion, :count).by(0) - .and change(TagDescription, :count).by(0) - .and change(TagDescriptionVersion, :count).by(0) - - expect(response).to have_http_status(:unprocessable_entity) - expect(page.reload.attributes).to eq(original) - expect(page.current_revision).to have_attributes(message: 'legacy edit') - end - +RSpec.describe 'Removed standalone Wiki resources', type: :request do include TagGroupRecords + let(:user) { create_member_user! } - [:id, :title].each do |lookup| - [nil, :historical].each do |edition| - it "rejects legacy #{ lookup } #{ edition || 'current' } without legacy SELECTs" do - path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }" - params = edition ? { version: first_revision.id } : { } - without_group_legacy_reads { get path, params: } - expect(response).to have_http_status(:not_found) - end - end - - it "does not recognise legacy #{ lookup } existence" do - path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }" - without_group_legacy_reads { get "#{ path }/exists" } - expect(response).to have_http_status(:not_found) - end - end - - ['/wiki', '/wiki/search', '/wiki/changes'].each do |path| - it "excludes unmigrated content from #{ path } without legacy SELECTs" do + ['Help:old', 'ヘルプ:旧ページ'].each do |title| + it "does not resolve #{ title } as a Wiki resource" do + path = "/wiki/title/#{ CGI.escapeURIComponent(title) }" without_group_legacy_reads { get path } - expect(response).to have_http_status(:ok) - expect(json).to eq([]) - end - end - - it 'returns empty scoped history without legacy SELECTs' do - without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } } - expect(response).to have_http_status(:ok) - expect(json).to eq([]) - end - - [false, true].each do |explicit| - it "rejects legacy diff with #{ explicit ? 'explicit' : 'current' } target" do - params = { from: first_revision.id } - params[:to] = last_revision.id if explicit - without_group_legacy_reads { get "/wiki/#{ page.id }/diff", params: } expect(response).to have_http_status(:not_found) + get "#{ path }/exists" + expect(response).to have_http_status(:not_found) + end + + it "rejects standalone creation of #{ title } without writes" do + user + before_state = group_state + post '/wiki', params: { title:, body: 'body' }, + headers: { 'X-Transfer-Code' => user.inheritance_code } + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) end end end diff --git a/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb b/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb index 40f9559..a6d908f 100644 --- a/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb +++ b/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb @@ -9,9 +9,9 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do expect(ApplicationRecord.connection.adapter_name).to match(/mysql/i) expect(ApplicationRecord.connection_pool.size).to be >= 3 @prefix = "shared_ids_#{ SecureRandom.hex(8) }" - @names = [0, 1].map { create(:tag_name, name: "#{ @prefix }_#{ _1 }") } - @tag = Tag.create!(tag_name_id: @names.first.id, category: :general) - @names.first.update!(tag_id: @tag.id) + @tag = create(:tag, primary_name: "#{ @prefix }_owner") + @group = TagGroup.create!(name: "#{ @prefix }_owner", version_no: 1) + @tag_ids = Queue.new @threads = [] @release = Queue.new end @@ -23,17 +23,21 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do raise 'Shared ID worker did not finish' unless thread.join(15) end ensure + TagDescriptionVersion.where(tag_id: @tag.id).delete_all if @tag + if @group + TagGroupVersion.where(tag_group_id: @group.id).delete_all + @group.delete + end if @prefix ExternalTag.where(name: "#{ @prefix }_external").delete_all TagGroup.where(name: "#{ @prefix }_group").delete_all end TagDescription.where(tag_id: @tag.id).delete_all if @tag - if @names - ids = @names.map(&:id) - TagName.where(id: ids).update_all(tag_id: nil) - Tag.where(tag_name_id: ids).delete_all - TagName.where(id: ids).delete_all - end + ids = [] + ids << @tag_ids.pop until @tag_ids.empty? if @tag_ids + ids << @tag.id if @tag + TagName.where(tag_id: ids).delete_all + Tag.where(id: ids).delete_all end end @@ -51,11 +55,19 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do def create_resource kind case kind when :tag - Tag.create!(tag_name_id: @names.last.id, category: :general) + Tag.create!(category: :general).tap { @tag_ids << _1.id } when :external ExternalTag.create!(platform: :nico, name: "#{ @prefix }_external") when :description TagDescription.create!(tag: @tag, language_code: 'ja', body: '', version_no: 1) + when :description_version + TagDescriptionVersion.create!( + tag_id: @tag.id, language_code: 'ja', body: 'body', version_no: 1, + event_type: 'create', created_at: Time.current) + when :group_version + TagGroupVersion.create!( + tag_group: @group, name: @group.name, version_no: 1, event_type: 'create', + members: [], placements: [], created_at: Time.current) when :group TagGroup.create!(name: "#{ @prefix }_group", version_no: 1) end @@ -88,7 +100,8 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do thread.value end - { tags: [:external, :tag], wiki: [:description, :group] }.each do |space, kinds| + { tags: [:external, :tag], wiki: [:description, :group], + wiki_history: [:description_version, :group_version] }.each do |space, kinds| [false, true].each do |rollback| it "serialises #{ space } inserts through #{ rollback ? 'rollback' : 'commit' }" do tables = SharedResourceIdAllocator::SPACES.fetch(space.to_s) @@ -96,7 +109,7 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do ApplicationRecord.connection.select_value("SELECT MAX(id) FROM #{ table }").to_i end first, (first_id, connection_id, lock_name) = hold_creation(kinds.first, rollback:) - expect(first_id).to eq(maxima.max + 1) + expect(first_id).to be > maxima.max connection = ApplicationRecord.connection quoted_lock = connection.quote(lock_name) expect(connection.select_value("SELECT IS_USED_LOCK(#{ quoted_lock })").to_i) @@ -110,7 +123,7 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do expect(await_queue(started)).not_to eq(connection_id) @release << true expect(result(first)).to eq(first_id) - expect(result(second)).to eq(rollback ? first_id : first_id + 1) + expect(result(second)).to be > first_id expect(connection.select_value("SELECT IS_FREE_LOCK(#{ quoted_lock })").to_i).to eq(1) end end diff --git a/backend/spec/services/shared_resource_id_allocator_spec.rb b/backend/spec/services/shared_resource_id_allocator_spec.rb index 823fb4a..a972036 100644 --- a/backend/spec/services/shared_resource_id_allocator_spec.rb +++ b/backend/spec/services/shared_resource_id_allocator_spec.rb @@ -1,6 +1,100 @@ require 'rails_helper' RSpec.describe SharedResourceIdAllocator do + let(:tag) { create(:tag) } + + def resource table, id: nil, number: 1 + case table + when 'tags' + Tag.new(id:, category: :general) + when 'external_tags' + ExternalTag.new(id:, platform: :nico, name: "external #{ number }") + when 'tag_descriptions' + TagDescription.new(id:, tag:, language_code: 'ja', body: 'body', version_no: number) + when 'tag_groups' + TagGroup.new(id:, name: "group #{ number }", version_no: number) + when 'tag_description_versions' + TagDescriptionVersion.new( + id:, tag_id: tag.id, language_code: 'ja', body: 'body', version_no: number, + event_type: 'create', created_at: Time.current) + when 'tag_group_versions' + TagGroupVersion.new( + id:, tag_group_id: group.id, name: group.name, version_no: number, + event_type: 'create', members: [], placements: [], created_at: Time.current) + end + end + + let(:group) { TagGroup.create!(name: 'history owner', version_no: 1) } + + described_class::SPACES.each do |space, tables| + tables.permutation.each do |first_table, second_table| + it "keeps deleted #{ first_table } IDs reserved for #{ second_table }" do + first = resource(first_table) + first.save! + used_id = first.id + first.class.where(id: first.id).delete_all + second = resource(second_table) + second.save! + expect(second.id).to be > used_id + end + + it "advances #{ space } after an explicit INSERT in #{ first_table }" do + first = resource(first_table) + first.save! + high = first.id + 100 + explicit = resource(first_table, id: high, number: 2) + first.class.where(id: first.id).delete_all + explicit.save! + explicit.class.where(id: explicit.id).delete_all + second = resource(second_table) + second.save! + expect(second.id).to be > high + end + end + + it "rejects explicit IDs below the reserved #{ space } position" do + first = resource(tables.first) + first.save! + first.class.where(id: first.id).delete_all + expect { resource(tables.last, id: first.id).save! } + .to raise_error(ActiveRecord::RecordNotUnique) + end + + it "preserves historical #{ space } overlaps without repairing them" do + first = resource(tables.first) + first.save! + second = resource(tables.last) + second.save! + second.class.where(id: second.id).update_all(id: first.id) + second = second.class.find(first.id) + snapshots = [first.reload.attributes, second.reload.attributes] + next_record = resource(tables.last, number: 2) + next_record.save! + expect(next_record.id).to be > first.id + expect(first.reload.attributes).to eq(snapshots.first) + expect(second.reload.attributes).to eq(snapshots.last) + end + end + + it 'reserves a Tag ID referenced only by remaining description history' do + high = [Tag.maximum(:id).to_i, ExternalTag.maximum(:id).to_i].max + 1000 + TagDescriptionVersion.create!( + tag_id: high, language_code: 'ja', version_no: 1, body: 'history', + event_type: 'create', created_at: Time.current) + expect(create(:tag).id).to be > high + expect(create(:external_tag).id).to be > high + end + + it 'reserves a group ID referenced only by remaining group history' do + high = [TagGroup.maximum(:id).to_i, TagDescription.maximum(:id).to_i].max + 1000 + version = resource('tag_group_versions') + version.save! + TagGroupVersion.where(id: version.id).update_all(tag_group_id: high) + expect(TagGroup.create!(name: 'replacement', version_no: 1).id).to be > high + end +end + +RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do let(:record) { build(:external_tag) } let(:connection) { double('connection') } let(:transaction) { double('transaction', open?: true) } @@ -11,6 +105,14 @@ RSpec.describe SharedResourceIdAllocator do config = double('config', database: 'test_shared_ids') allow(connection).to receive_message_chain(:pool, :db_config).and_return(config) allow(connection).to receive(:quote) { "'#{ _1 }'" } + allow(connection).to receive(:quote_column_name) { "`#{ _1 }`" } + allow(connection).to receive(:execute) { sql << _1 } + allow(connection).to receive(:select_all) do |statement| + tables = described_class::SPACES.values.flatten.select do |table| + statement.include?("'#{ table }'") + end + tables.map { { 'TABLE_NAME' => _1, 'ENGINE' => 'InnoDB', 'AUTO_INCREMENT' => 9 } } + end allow(connection).to receive(:quote_table_name) { "`#{ _1 }`" } allow(connection).to receive(:select_value) do |statement| sql << statement @@ -35,7 +137,6 @@ RSpec.describe SharedResourceIdAllocator do described_class.assign!(record) expect(record.id).to eq(9) expect(sql.grep(/RELEASE_LOCK/)).to be_empty - expect(sql.grep(/FOR UPDATE/).size).to eq(2) callbacks.fetch(event).call expect(sql.grep(/RELEASE_LOCK/).size).to eq(1) end @@ -49,22 +150,22 @@ RSpec.describe SharedResourceIdAllocator do end it 'releases a successfully acquired lock when allocation fails and rolls back' do - allow(connection).to receive(:select_value).with(/FOR UPDATE/).and_raise('query failed') + allow(connection).to receive(:select_all).and_raise('query failed') expect { described_class.assign!(record) }.to raise_error(RuntimeError, 'query failed') callbacks.fetch(:after_rollback).call expect(sql.grep(/RELEASE_LOCK/).size).to eq(1) end - it 'preserves explicitly supplied historical IDs' do - record.id = 5 + it 'accepts an explicit ID above the reserved position' do + record.id = 50 described_class.assign!(record) - expect(record.id).to eq(5) + expect(record.id).to eq(50) expect(sql.grep(/FOR UPDATE/)).to be_empty callbacks.fetch(:after_commit).call end it 'uses the same named lock within each space and different locks between spaces' do - locks = [Tag, ExternalTag, TagDescription, TagGroup].map do |model| + locks = [Tag, ExternalTag, TagDescription, TagGroup, TagDescriptionVersion, TagGroupVersion].map do |model| allow(model).to receive(:connection).and_return(connection) allow(model).to receive(:current_transaction).and_return(transaction) allow(model).to receive(:uncached).and_yield @@ -76,7 +177,8 @@ RSpec.describe SharedResourceIdAllocator do end expect(locks[0]).to eq(locks[1]) expect(locks[2]).to eq(locks[3]) - expect(locks[0]).not_to eq(locks[2]) + expect(locks[4]).to eq(locks[5]) + expect([locks[0], locks[2], locks[4]].uniq.size).to eq(3) end it 'reports lock release failures' do diff --git a/backend/spec/services/wiki/commit_integrity_spec.rb b/backend/spec/services/wiki/commit_integrity_spec.rb deleted file mode 100644 index e58a60c..0000000 --- a/backend/spec/services/wiki/commit_integrity_spec.rb +++ /dev/null @@ -1,185 +0,0 @@ -require 'digest' -require 'rails_helper' - -RSpec.describe Wiki::Commit do - include WikiRecords - let(:user) { create_member_user! } - - def create_page title:, body: 'initial body' - seed_wiki_page( - tag_name: create(:tag, primary_name: title).tag_name('ja'), - body:, - created_by_user: user, - message: 'init') - end - - describe '.create_content!' do - it 'rejects standalone Help creation without adding page or history' do - name = create(:tag_name, name: 'Help:frozen_service') - counts = [WikiPage.count, WikiRevision.count, WikiVersion.count] - expect do - described_class.create_content!(tag_name: name, body: 'initial', created_by_user: user) - end.to raise_error(ActiveRecord::RecordInvalid) - expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts) - end - - it 'keeps the internal compatibility writer available' do - name = create(:tag_name, name: 'タググループ:compatibility_service') - page = nil - expect do - page = described_class.create_legacy_content!( - tag_name: name, body: 'initial', created_by_user: user) - end.to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - expect(page.current_revision.body).to eq('initial') - expect(page.wiki_versions.sole.body).to eq('initial') - end - - it 'rejects body that becomes blank after normalisation' do - tag_name = create(:tag, primary_name: 'commit_integrity_blank').tag_name('ja') - - expect { - described_class.create_content!( - tag_name:, - body: "\r\n\r\n", - created_by_user: user, - message: 'blank') - } - .to raise_error(ActiveRecord::RecordInvalid) - - expect(WikiPage.where(tag_name:)).not_to exist - end - end - - describe '.content!' do - it 'rejects standalone Help updates without modifying histories' do - page = seed_wiki_page(tag_name: create(:tag_name, name: 'Help:standalone'), - body: 'before', created_by_user: user) - counts = [WikiRevision.count, WikiVersion.count] - expect do - described_class.content!(page:, body: 'after', created_user: user) - end.to raise_error(ActiveRecord::RecordInvalid) - expect(page.reload.body).to eq('before') - expect([WikiRevision.count, WikiVersion.count]).to eq(counts) - end - - it 'updates page body, revision, and version' do - page = create_page(title: 'commit_integrity_update', body: 'before') - current_id = page.current_revision.id - - expect { - described_class.content!( - page:, - body: 'after', - created_user: user, - message: 'edit', - base_revision_id: current_id) - } - .to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - - page.reload - version = page.wiki_versions.order(:version_no).last - - expect(page.body).to eq('after') - expect(version.body).to eq('after') - expect(version.reason).to eq('edit') - end - - it 'does not record tag_version on body-only wiki update' do - tag_name = create(:tag_name, name: 'commit_integrity_linked_tag') - tag = create(:tag, primary_tag_name: tag_name, category: :general) - - page = - seed_wiki_page( - tag_name:, - body: 'before', - created_by_user: user, - message: 'init') - - TagVersionRecorder.record!( - tag:, - event_type: :create, - created_by_user: user) - - before_count = tag.reload.tag_versions.count - - described_class.content!( - page:, - body: 'after', - created_user: user, - message: 'edit', - base_revision_id: page.current_revision.id) - - expect(tag.reload.tag_versions.count).to eq(before_count) - end - - it 'raises conflict and leaves page, revision, and version unchanged' do - page = create_page(title: 'commit_integrity_conflict', body: 'first') - stale_id = page.current_revision.id - - described_class.content!( - page:, - body: 'second', - created_user: user, - message: 'second', - base_revision_id: stale_id) - - page.reload - before_body = page.body - before_revision_count = page.wiki_revisions.count - before_version_count = page.wiki_versions.count - - expect { - described_class.content!( - page:, - body: 'third', - created_user: user, - message: 'stale', - base_revision_id: stale_id) - } - .to raise_error(Wiki::Commit::Conflict) - - page.reload - expect(page.body).to eq(before_body) - expect(page.wiki_revisions.count).to eq(before_revision_count) - expect(page.wiki_versions.count).to eq(before_version_count) - end - - it 'deduplicates duplicated missing wiki lines' do - page = create_page(title: 'commit_integrity_dedup', body: 'before') - duplicated = 'commit_integrity_duplicate_line' - - described_class.content!( - page:, - body: "#{ duplicated }\n#{ duplicated }", - created_user: user, - message: 'dedup', - base_revision_id: page.current_revision.id) - - revision = page.reload.current_revision - - expect(WikiLine.where(body: duplicated).count).to eq(1) - expect(revision.wiki_revision_lines.count).to eq(2) - expect(revision.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1) - end - end - - describe '.redirect!' do - it 'raises because redirect revisions are deprecated' do - page = create_page(title: 'commit_integrity_redirect_source', body: 'source') - target = create_page(title: 'commit_integrity_redirect_target', body: 'target') - - expect { - described_class.redirect!( - page:, - redirect_page: target, - created_user: user, - message: 'redirect', - base_revision_id: page.current_revision.id) - } - .to raise_error(RuntimeError, '廃止しました.') - end - end -end diff --git a/backend/spec/services/wiki/commit_spec.rb b/backend/spec/services/wiki/commit_spec.rb deleted file mode 100644 index 6acd297..0000000 --- a/backend/spec/services/wiki/commit_spec.rb +++ /dev/null @@ -1,151 +0,0 @@ -require 'rails_helper' - -RSpec.describe Wiki::Commit do - include WikiRecords - let(:user) { create_member_user! } - - def create_page(title: 'commit_spec_page', body: 'initial body') - tag_name = create(:tag, primary_name: title).tag_name('ja') - - seed_wiki_page( - tag_name:, - body:, - created_by_user: user, - message: 'init') - end - - describe '.content!' do - it 'stores normalised body in wiki_pages and wiki_versions' do - page = create_page(title: 'commit_normalised_page') - - described_class.content!( - page:, - body: "a\r\nb\r\n\r\n", - created_user: user, - message: 'init' - ) - - page.reload - version = page.wiki_versions.order(:version_no).last - - expect(page.body).to eq("a\nb") - expect(version.body).to eq("a\nb") - expect(page.current_revision.lines_count).to eq(2) - end - - it 'deduplicates duplicated missing wiki lines before upsert' do - page = create_page(title: 'commit_duplicate_line_page') - duplicated = 'commit_duplicate_line' - - described_class.content!( - page:, - body: "#{ duplicated }\n#{ duplicated }", - created_user: user, - message: 'init' - ) - - page.reload - - expect(WikiLine.where(body: duplicated).count).to eq(1) - expect(page.current_revision.lines_count).to eq(2) - expect(page.current_revision.wiki_revision_lines.count).to eq(2) - end - - it 'raises conflict when base_revision_id is stale' do - page = create_page(title: 'commit_conflict_page') - - first = described_class.content!( - page:, - body: 'first', - created_user: user, - message: 'first' - ) - - described_class.content!( - page:, - body: 'second', - created_user: user, - message: 'second', - base_revision_id: first.id - ) - - expect { - described_class.content!( - page:, - body: 'third', - created_user: user, - message: 'third', - base_revision_id: first.id - ) - }.to raise_error(Wiki::Commit::Conflict) - end - - it 'does not record tag version when corresponding tag has no versions' do - tag_name = create(:tag_name, name: 'commit_linked_tag_without_versions') - tag = create(:tag, primary_tag_name: tag_name, category: :general) - - page = - seed_wiki_page( - tag_name:, - body: 'before', - created_by_user: user, - message: 'init') - - expect(tag.reload.tag_versions.count).to eq(0) - - current_revision_id = page.current_revision.id - - expect { - described_class.content!( - page:, - body: 'after', - created_user: user, - message: 'edit', - base_revision_id: current_revision_id) - }.to change(WikiVersion, :count).by(1) - - expect(tag.reload.tag_versions.count).to eq(0) - end - - it 'does not record tag version when corresponding tag has no versions' do - tag_name = create(:tag_name, name: 'commit_linked_tag_without_versions') - tag = create(:tag, primary_tag_name: tag_name, category: :general) - - page = - seed_wiki_page( - tag_name:, - body: 'before', - created_by_user: user, - message: 'init') - - current_revision_id = page.current_revision.id - - expect { - described_class.content!( - page:, - body: 'after', - created_user: user, - message: 'edit', - base_revision_id: current_revision_id) - }.to change(WikiVersion, :count).by(1) - - expect(tag.reload.tag_versions.count).to eq(0) - end - end - - describe '.redirect!' do - it 'raises because redirect revisions are deprecated' do - page = create_page(title: 'commit_redirect_source') - target = create_page(title: 'commit_redirect_target') - - expect { - described_class.redirect!( - page:, - redirect_page: target, - created_user: user, - message: 'redirect' - ) - }.to raise_error(RuntimeError, '廃止しました.') - end - end -end diff --git a/backend/spec/services/wiki_version_recorder_spec.rb b/backend/spec/services/wiki_version_recorder_spec.rb deleted file mode 100644 index 6c1457c..0000000 --- a/backend/spec/services/wiki_version_recorder_spec.rb +++ /dev/null @@ -1,100 +0,0 @@ -require 'rails_helper' - -RSpec.describe WikiVersionRecorder do - include WikiRecords - let(:user) { create_member_user! } - - def create_page title:, body: 'body' - seed_wiki_page( - tag_name: create(:tag_name, name: title), - body:, - created_by_user: user, - message: 'init') - end - - describe '.record!' do - it 'records title, body, reason, user, and version number' do - page = create_page(title: 'wiki_version_recorder_basic', body: 'body') - - expect { - described_class.record!( - page:, - event_type: :update, - reason: 'manual reason', - created_by_user: user) - } - .to change { page.reload.wiki_versions.count }.by(1) - - version = page.wiki_versions.order(:version_no).last - - expect(version).to have_attributes( - version_no: 2, - event_type: 'update', - title: 'wiki_version_recorder_basic', - body: 'body', - reason: 'manual reason', - created_by_user_id: user.id - ) - end - - it 'does not create duplicated update version for identical snapshot' do - page = create_page(title: 'wiki_version_recorder_duplicate', body: 'body') - - described_class.record!( - page:, - event_type: :update, - reason: nil, - created_by_user: user) - - before_count = page.reload.wiki_versions.count - - described_class.record!( - page:, - event_type: :update, - reason: nil, - created_by_user: user) - - expect(page.reload.wiki_versions.count).to eq(before_count) - end - - it 'creates update version when title changes' do - page = create_page(title: 'wiki_version_recorder_title_before', body: 'body') - page.tag_name.update!(name: 'wiki_version_recorder_title_after') - - expect { - described_class.record!( - page:, - event_type: :update, - reason: 'rename', - created_by_user: user) - } - .to change { page.reload.wiki_versions.count }.by(1) - - version = page.wiki_versions.order(:version_no).last - - expect(version.title).to eq('wiki_version_recorder_title_after') - expect(version.body).to eq('body') - expect(version.reason).to eq('rename') - end - - it 'creates update version when body changes' do - page = create_page(title: 'wiki_version_recorder_body', body: 'before') - page.update!(body: 'after') - - expect { - described_class.record!( - page:, - event_type: :update, - reason: 'body', - created_by_user: user) - } - .to change { page.reload.wiki_versions.count }.by(1) - - version = page.wiki_versions.order(:version_no).last - - expect(version.title).to eq('wiki_version_recorder_body') - expect(version.body).to eq('after') - expect(version.reason).to eq('body') - end - end -end diff --git a/backend/spec/support/tag_group_records.rb b/backend/spec/support/tag_group_records.rb index ed3a929..9d370c7 100644 --- a/backend/spec/support/tag_group_records.rb +++ b/backend/spec/support/tag_group_records.rb @@ -7,8 +7,7 @@ module TagGroupRecords 'tag_groups', 'tag_group_tags', 'tag_group_groups', 'tag_group_placements', 'tag_group_versions'].freeze WRITE_TABLES = GROUP_TABLES + [ - 'wiki_pages', 'wiki_revisions', 'wiki_versions', 'wiki_lines', - 'wiki_revision_lines', 'tag_names', 'tag_description_versions', + 'tag_names', 'tag_description_versions', 'tag_descriptions'] def group_connection = ApplicationRecord.connection @@ -34,13 +33,11 @@ module TagGroupRecords WRITE_TABLES.to_h { |table| [table, group_rows(table)] } end - def expect_noop_group_update group_id:, page: + def expect_noop_group_update group_id: before_state = group_state group = TagGroup.find(group_id) version_id = TagGroupVersion.find_by!( tag_group_id: group_id, version_no: group.version_no).id - revision_id = page.reload.current_revision.id - wiki_version_id = page.wiki_versions.order(version_no: :desc).first.id writes = [] tables = Regexp.union(WRITE_TABLES) subscriber = lambda do |*args| @@ -59,8 +56,6 @@ module TagGroupRecords end expect(TagGroupVersion.find_by!( tag_group_id: group_id, version_no: group.reload.version_no).id).to eq(version_id) - expect(page.reload.current_revision.id).to eq(revision_id) - expect(page.wiki_versions.order(version_no: :desc).first.id).to eq(wiki_version_id) expect(writes).to be_empty end end @@ -99,25 +94,13 @@ module TagGroupRecords tag.reload end - def legacy_group name, body - # Historical fixtures preserve blank lines independently of today's writer. - body = body.sub(/\n+\z/, '') - page = WikiPage.create!( - tag_name: create(:tag_name, name: "タググループ:#{ name }"), - body:, created_user: user, updated_user: user) - WikiVersionRecorder.record!(page:, event_type: :create, created_by_user: user) - lines = body.split("\n") - shas = lines.map { Digest::SHA256.hexdigest(_1) } - revision = WikiRevision.create!( - wiki_page: page, kind: :content, created_user: user, lines_count: lines.size, - tree_sha256: Digest::SHA256.hexdigest(shas.join(','))) - lines.each_with_index do |line, position| - sha = shas.fetch(position) - line_id = WikiLine.find_by(sha256: sha)&.id || insert_group_row('wiki_lines', { - body: line, sha256: sha, created_at: Time.current, updated_at: Time.current }) - WikiRevisionLine.create!(wiki_revision: revision, wiki_line_id: line_id, position:) - end - page + def create_markdown_group name, body + parsed = TagGroupMarkdownParser.parse(body:) + TagGroupOperation.create!(name:, actor: user, **parsed.slice(:description, :members, :placements)) + end + + def group_markdown group + TagGroupMarkdownRenderer.render(TagGroupRepr.one(group.reload)) end def seed_group name:, placements: [], members: [], description: nil, id: nil @@ -176,16 +159,6 @@ module TagGroupRecords "/wiki/title/#{ CGI.escapeURIComponent("タググループ:#{ name }") }" end - def migrate_groups - require_relative '../../db/migrate/20261008010000_migrate_tag_groups_from_wiki_pages' - migration = MigrateTagGroupsFromWikiPages.new - @group_migration_messages = [] - allow(migration).to receive(:say) do |message, *_args| - @group_migration_messages << message if message.start_with?('Wiki #', 'Tag Group') - end - migration.suppress_messages { migration.up } - end - def expect_group_snapshot group_id, number:, placements:, members:, description: nil current = group_rows('tag_groups', id: group_id).sole version = group_version(group_id, number) diff --git a/backend/spec/support/wiki_records.rb b/backend/spec/support/wiki_records.rb index f321aba..4dbcfb1 100644 --- a/backend/spec/support/wiki_records.rb +++ b/backend/spec/support/wiki_records.rb @@ -1,20 +1,14 @@ -# Historical records must remain seedable after application creation is frozen. module WikiRecords - def seed_wiki_page(tag_name:, body:, created_by_user:, message: nil) - page = WikiPage.create!(tag_name:, body:, - created_user: created_by_user, updated_user: created_by_user) - revision = Wiki::Commit.legacy_content!( - page:, body:, created_user: created_by_user, message:) - if tag_name.tag && tag_name.language_code == 'ja' - TagDescription.create!( - id: page.id, tag: tag_name.tag, language_code: 'ja', body: page.reload.body, - version_no: 1, created_by_user_id: created_by_user.id, - updated_by_user_id: created_by_user.id) - TagDescriptionVersion.create!( - id: revision.id, tag_id: tag_name.tag_id, language_code: 'ja', - version_no: 1, event_type: 'create', body: page.body, - created_by_user_id: created_by_user.id, created_at: revision.created_at) - end - page + def seed_description(tag_name:, body:, created_by_user:, message: nil) + TagDescriptionOperation.create!(tag: tag_name.tag, body:, created_by_user:, message:) + end + + def description_versions(description) + TagDescriptionVersion.where( + tag_id: description.tag_id, language_code: description.language_code) + end + + def current_description_version(description) + description_versions(description).find_by!(version_no: description.reload.version_no) end end