From 890ffb57e1281e1dc1121e7ab13bca93d0ac204f Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 9 Oct 2026 20:38:14 +0900 Subject: [PATCH] #425 --- backend/spec/db/tag_group_constraints_spec.rb | 110 +++++++ backend/spec/db/tag_group_migration_spec.rb | 119 +++++++ .../spec/requests/tag_group_markdown_spec.rb | 117 +++++++ backend/spec/requests/tag_group_wiki_spec.rb | 155 +++++++++ .../requests/tag_group_wiki_write_spec.rb | 232 +++++++++++++ backend/spec/requests/tag_groups_spec.rb | 308 ++++++++++++++++++ backend/spec/support/tag_group_records.rb | 189 +++++++++++ 7 files changed, 1230 insertions(+) create mode 100644 backend/spec/db/tag_group_constraints_spec.rb create mode 100644 backend/spec/db/tag_group_migration_spec.rb create mode 100644 backend/spec/requests/tag_group_markdown_spec.rb create mode 100644 backend/spec/requests/tag_group_wiki_spec.rb create mode 100644 backend/spec/requests/tag_group_wiki_write_spec.rb create mode 100644 backend/spec/requests/tag_groups_spec.rb create mode 100644 backend/spec/support/tag_group_records.rb diff --git a/backend/spec/db/tag_group_constraints_spec.rb b/backend/spec/db/tag_group_constraints_spec.rb new file mode 100644 index 0000000..bdbe417 --- /dev/null +++ b/backend/spec/db/tag_group_constraints_spec.rb @@ -0,0 +1,110 @@ +require 'rails_helper' + +RSpec.describe 'Tag group persistence constraints' do + include TagGroupRecords + + let!(:user) { create_member_user! } + let!(:tag) { group_tag('制約確認') } + let!(:group_id) do + seed_group( + name: '制約確認', + members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }]) + end + + def placement attributes + insert_group_row('tag_group_placements', { + tag_group_id: group_id, position: 1, + created_at: Time.current, updated_at: Time.current }.merge(attributes)) + end + + def reject_statement + before_state = group_state + expect { yield }.to raise_error(ActiveRecord::StatementInvalid) + expect(group_state).to eq(before_state) + end + + it 'allows membership without placement' do + expect(group_rows('tag_group_tags', tag_group_id: group_id).size).to eq(1) + expect(group_rows('tag_group_placements', tag_group_id: group_id)).to be_empty + end + + it 'allows repeated Tag placements with unique membership' do + placement(tag_id: tag.id) + placement(tag_id: tag.id, position: 2) + expect(group_rows('tag_group_tags', tag_group_id: group_id).size).to eq(1) + expect(group_rows('tag_group_placements', tag_group_id: group_id).size).to eq(2) + end + + it 'rejects duplicate Tag membership' do + reject_statement do + insert_group_row('tag_group_tags', { + tag_group_id: group_id, tag_id: tag.id, created_at: Time.current }) + end + end + + it 'rejects duplicate child membership' do + child_id = seed_group(name: '子') + attributes = { tag_group_id: group_id, child_tag_group_id: child_id, + created_at: Time.current } + insert_group_row('tag_group_groups', attributes) + reject_statement { insert_group_row('tag_group_groups', attributes) } + end + + it 'rejects direct self membership' do + reject_statement do + insert_group_row('tag_group_groups', { + tag_group_id: group_id, child_tag_group_id: group_id, created_at: Time.current }) + end + end + + it 'rejects a placement without a kind' do + reject_statement { placement({ }) } + end + + it 'rejects simultaneous Section and Tag kinds' do + reject_statement { placement(section_name: '区分', tag_id: tag.id) } + end + + it 'rejects a description attached to a Tag placement' do + reject_statement { placement(tag_id: tag.id, description: '不正') } + end + + it 'allows an optional description on a Section' do + placement(section_name: '区分', description: '説明') + expect(group_rows('tag_group_placements', tag_group_id: group_id).sole) + .to include('section_name' => '区分', 'description' => '説明') + end + + it 'rejects a Tag placement absent from membership' do + other = group_tag('非メンバー') + reject_statement { placement(tag_id: other.id) } + end + + it 'rejects a missing parent placement' do + reject_statement { placement(section_name: '子', parent_position: 9) } + end + + it 'rejects a child ordered before its parent' do + placement(section_name: '親', position: 2) + reject_statement { placement(section_name: '子', position: 1, parent_position: 2) } + end + + it 'rejects duplicate group names' do + reject_statement { seed_group(name: '制約確認') } + end + + it 'rejects a missing mandatory group name' do + reject_statement { seed_group(name: nil) } + end + + it 'rejects a six-level snapshot' do + nodes = [] + 6.times { nodes = [{ 'tag_id' => tag.id, 'children' => nodes }] } + reject_statement do + insert_group_row('tag_group_versions', { + tag_group_id: group_id, version_no: 2, event_type: 'update', name: '制約確認', + members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }].to_json, + placements: nodes.to_json, created_at: Time.current }) + end + end +end diff --git a/backend/spec/db/tag_group_migration_spec.rb b/backend/spec/db/tag_group_migration_spec.rb new file mode 100644 index 0000000..c9e7566 --- /dev/null +++ b/backend/spec/db/tag_group_migration_spec.rb @@ -0,0 +1,119 @@ +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/requests/tag_group_markdown_spec.rb b/backend/spec/requests/tag_group_markdown_spec.rb new file mode 100644 index 0000000..79d571d --- /dev/null +++ b/backend/spec/requests/tag_group_markdown_spec.rb @@ -0,0 +1,117 @@ +require 'rails_helper' + +RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :request do + include TagGroupRecords + + let!(:user) { create_member_user! } + let!(:series) { group_tag('ぼざろクリーチャーシリーズ') } + let!(:deer) { group_tag('洗澡鹿') } + let!(:nijika) { group_tag('伊地知ニジカ') } + let!(:wanted) { group_tag('タグ希望') } + let!(:character) { group_tag('キャラクター名希望') } + let!(:music) { group_tag('楽曲情報希望') } + let!(:bot) { group_tag('bot操作') } + let!(:unknown) { group_tag('ニジラー情報不詳') } + let!(:child_id) { seed_group(name: '伊地知ニジカ亜種', description: '子の説明') } + + before { I18n.locale = :ja } + + def tag_node tag, children = [] + { 'tag_id' => tag.id, 'children' => children } + end + + def tag_member tag + { 'tag_id' => tag.id, 'version_no' => tag.version_no } + end + + def check_round_trip placements:, members:, markdown:, description: nil + id = seed_group(name: '変換元', placements:, members:, description:) + without_group_legacy_reads { get group_title_path('変換元') } + expect(response).to have_http_status(:ok) + expect(json).to include('id' => id) + body = json.fetch('body') + expect(body).not_to include("\r") + expect(body.lines.map(&:chomp)).to all(satisfy { |line| line == line.rstrip }) + # A trailing LF or a blank line between blocks is semantically irrelevant. + expect(body.lines.map(&:chomp).reject(&:empty?)).to eq( + markdown.lines.map(&:chomp).reject(&:empty?)) + + post '/wiki', params: { title: 'タググループ:再解析', body: }, + headers: { 'X-Transfer-Code' => user.inheritance_code } + expect(response).to have_http_status(:created) + parsed = group_rows('tag_groups', name: '再解析').sole + expect_group_snapshot(parsed.fetch('id'), number: 1, placements:, members:, description:) + end + + it 'renders a list Section under a Tag with two-space indentation' do + check_round_trip( + members: [series, deer].map { tag_member(_1) }, + placements: [tag_node(series, [ + { 'section_name' => '伊地知ニジカ亜種', 'description' => nil, + 'children' => [tag_node(deer)] }])], + markdown: <<~MARKDOWN) + - ぼざろクリーチャーシリーズ + - 伊地知ニジカ亜種: + - 洗澡鹿 + MARKDOWN + end + + it 'renders a child Tag Group distinctly from its parent Tags' do + check_round_trip( + members: [tag_member(series), tag_member(nijika), + { 'tag_group_id' => child_id, 'version_no' => 1 }], + placements: [tag_node(series, [tag_node(nijika, [ + { 'tag_group_id' => child_id, 'children' => [] }])])], + markdown: <<~MARKDOWN) + - ぼざろクリーチャーシリーズ + - 伊地知ニジカ + - タググループ:伊地知ニジカ亜種 + MARKDOWN + end + + it 'renders top-level heading Sections, descriptions and ordered nested Tags' do + check_round_trip( + description: '全体の説明', + members: [wanted, character, music, bot, unknown].map { tag_member(_1) }, + placements: [ + { 'section_name' => '要求', 'description' => '要求の説明', + 'children' => [tag_node(wanted, [tag_node(character), tag_node(music)])] }, + { 'section_name' => '情報', 'description' => nil, + 'children' => [tag_node(bot), tag_node(unknown)] }], + markdown: <<~MARKDOWN) + 全体の説明 + + # 要求 + 要求の説明 + + - タグ希望 + - キャラクター名希望 + - 楽曲情報希望 + + # 情報 + - bot操作 + - ニジラー情報不詳 + MARKDOWN + end + + it 'renders nested heading Sections and all repeated Tag placements' do + check_round_trip( + members: [tag_member(deer)], + placements: [ + { 'section_name' => '親', 'description' => '親の説明', 'children' => [ + tag_node(deer), + { 'section_name' => '子', 'description' => '子の説明', + 'children' => [tag_node(deer)] }] }], + markdown: <<~MARKDOWN) + # 親 + 親の説明 + + - 洗澡鹿 + + ## 子 + 子の説明 + + - 洗澡鹿 + MARKDOWN + end +end diff --git a/backend/spec/requests/tag_group_wiki_spec.rb b/backend/spec/requests/tag_group_wiki_spec.rb new file mode 100644 index 0000000..e545729 --- /dev/null +++ b/backend/spec/requests/tag_group_wiki_spec.rb @@ -0,0 +1,155 @@ +require 'rails_helper' + +RSpec.describe 'Tag group Wiki facade reads', type: :request do + include TagGroupRecords + + 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!(:last_revision) do + Wiki::Commit.legacy_content!(page:, body: '- 新タグ', created_user: user) + 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 + + [:id, :title].each do |lookup| + context "with #{ lookup } lookup" do + let(:path) { lookup == :id ? "/wiki/#{ page.id }" : group_title_path('読取り契約') } + + it 'reads current structure without consulting migrated Wiki tables' do + without_group_legacy_reads { get path } + expect(response).to have_http_status(:ok) + expect(json).to include( + 'id' => page.id, 'title' => 'タググループ:読取り契約', + 'revision_id' => last_revision.id, 'pred' => first_revision.id, 'succ' => nil) + expect(json.fetch('body').strip).to eq('- 新タグ') + end + + it 'reads the old snapshot rather than current membership or placement' do + without_group_legacy_reads { get path, params: { version: first_revision.id } } + expect(response).to have_http_status(:ok) + expect(json.fetch('body').strip).to eq('- 旧タグ') + expect(json).to include( + 'revision_id' => first_revision.id, 'pred' => nil, 'succ' => last_revision.id, + 'updated_at' => first_revision.created_at.iso8601(3)) + end + + it 'checks existence without consulting migrated Wiki tables' do + without_group_legacy_reads { get "#{ path }/exists" } + expect(response).to have_http_status(:no_content) + end + + it 'does not fall back to legacy history when a group version is missing' do + group_connection.execute( + "DELETE FROM tag_group_versions WHERE id = #{ first_revision.id }") + without_group_legacy_reads { get path, params: { version: first_revision.id } } + expect(response).to have_http_status(:not_found) + end + end + end + + ['/wiki', '/wiki/search'].each do |path| + it "lists migrated groups once with current titles at #{ path }" do + get path, params: { title: 'タググループ:読取り' } + expect(response).to have_http_status(:ok) + rows = json.select { _1.fetch('title') == 'タググループ:読取り契約' } + expect(rows.size).to eq(1) + expect(rows.sole).to include('id' => page.id) + expect(response.body).not_to include('legacy stale') + end + end + + it 'combines Tag Groups, Help and Tag Descriptions in the unfiltered list' do + help = Wiki::Commit.create_legacy_content!( + 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( + 'タググループ:読取り契約', help.title, '新タグ') + end + + it 'reads group version IDs, editor and timestamps in scoped changes' do + without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } } + expect(response).to have_http_status(:ok) + expect(json.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id]) + expect(json.first).to include( + 'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content', + 'timestamp' => last_revision.created_at.iso8601(3), + 'user' => { 'id' => user.id, 'name' => user.name }, + 'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約')) + end + + it 'includes Help and group history but excludes legacy-only migrated revisions' do + extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user) + help = Wiki::Commit.create_legacy_content!( + 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).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 + 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(:ok) + expect(json).to include( + 'wiki_page_id' => page.id, 'older_revision_id' => first_revision.id, + 'newer_revision_id' => last_revision.id) + differences = json.fetch('diff').map { [_1.fetch('type'), _1.fetch('content').strip] } + expect(differences).to include(['removed', '- 旧タグ'], ['added', '- 新タグ']) + end + end + + it 'does not use a version belonging to another group' do + other_id = seed_group(name: '別グループ', description: '別本文') + other_version = group_version(other_id, 1) + get group_title_path('読取り契約'), params: { version: other_version.fetch('id') } + expect(response).to have_http_status(:not_found) + get "/wiki/#{ page.id }/diff", + params: { from: other_version.fetch('id'), to: last_revision.id } + expect(response).to have_http_status(:not_found) + end + + it 'resolves a group without any compatibility Wiki page' do + id = seed_group(name: '独立グループ', description: '独立した説明') + without_group_legacy_reads { get group_title_path('独立グループ') } + expect(response).to have_http_status(:ok) + expect(json).to include('id' => id, 'title' => 'タググループ:独立グループ') + expect(json.fetch('body').strip).to eq('独立した説明') + end + + it 'resolves colliding group, Help and description IDs by title' do + help = Wiki::Commit.create_legacy_content!( + 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', 'Help:ID衝突' => 'help', '新タグ' => '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 + end +end diff --git a/backend/spec/requests/tag_group_wiki_write_spec.rb b/backend/spec/requests/tag_group_wiki_write_spec.rb new file mode 100644 index 0000000..0af35f5 --- /dev/null +++ b/backend/spec/requests/tag_group_wiki_write_spec.rb @@ -0,0 +1,232 @@ +require 'rails_helper' + +RSpec.describe 'Tag group Wiki facade writes', type: :request do + include TagGroupRecords + include ActiveSupport::Testing::TimeHelpers + + let!(:user) { create_member_user! } + let!(:tag) { group_tag('洗澡鹿') } + let!(:other_tag) { group_tag('伊地知ニジカ') } + let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] } + let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] } + + before { I18n.locale = :ja } + + def create_group title: 'タググループ:新規', body: '- 洗澡鹿', request_headers: headers + post '/wiki', params: { title:, body: }, headers: request_headers + end + + def expect_rejected_without_writes + before_state = group_state + yield + aggregate_failures do + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + expect(response.media_type).to eq('application/json') + expect(response.body).not_to be_empty + next if response.body.empty? + + expect(json).to include( + 'type' => 'validation_error', 'errors' => a_kind_of(Hash), + 'base_errors' => a_kind_of(Array)) + end + end + + it 'creates the group, first snapshot and compatibility Wiki history' do + create_group + expect(response).to have_http_status(:created) + group = group_rows('tag_groups', name: '新規').sole + expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規') + expect_group_snapshot(group.fetch('id'), number: 1, members:, placements:) + 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.strip).to eq('- 洗澡鹿') + expect(page.current_revision.body).to eq(page.body) + expect(page.wiki_versions.sole.body).to eq(page.body) + # New IDs are independently allocated; no global identity assertion here. + end + + it 'keeps repeated placements but only one membership and one snapshot member' do + create_group(body: "- 洗澡鹿\n# 再掲\n- 洗澡鹿") + expect(response).to have_http_status(:created) + id = group_rows('tag_groups', name: '新規').sole.fetch('id') + expect_group_snapshot( + id, number: 1, members:, + placements: placements + [ + { 'section_name' => '再掲', 'description' => nil, 'children' => placements }]) + end + + it 'resolves a Japanese alias through tag_names.tag_id' do + create(:tag_name, :alias, tag:, name: '鹿の別名') + create_group(body: '- 鹿の別名') + expect(response).to have_http_status(:created) + id = group_rows('tag_groups', name: '新規').sole.fetch('id') + expect_group_snapshot(id, number: 1, members:, placements:) + get group_title_path('新規') + expect(response).to have_http_status(:ok) + expect(json.fetch('body').strip).to eq('- 洗澡鹿') + end + + { + 'unknown Tag' => '- 解決不能', + 'unknown Tag Group' => '- タググループ:存在しない', + 'empty Section' => '- :', + 'unsupported placement syntax' => "```\n洗澡鹿\n```", + 'six levels' => "- 洗澡鹿\n - 洗澡鹿\n - 洗澡鹿\n - 洗澡鹿\n" + + " - 洗澡鹿\n - 洗澡鹿", + 'blank body' => '', + 'normalised blank body' => "\r\n\r\n" }.each do |label, body| + it "rejects #{ label } without fallback or partial create" do + expect_rejected_without_writes { create_group(body:) } + end + end + + ['', 'タググループ:', "タググループ:#{ '長' * 256 }"].each do |title| + it "rejects an invalid required title of length #{ title.length }" do + expect_rejected_without_writes { create_group(title:) } + end + end + + it 'rejects a duplicate group name even without a matching legacy Wiki' do + seed_group(name: '新規', description: '既存') + expect_rejected_without_writes { create_group } + end + + it 'accepts the existing five-level placement depth boundary' do + body = (0...5).map { "#{ ' ' * _1 }- 洗澡鹿" }.join("\n") + create_group(body:) + expect(response).to have_http_status(:created) + id = group_rows('tag_groups', name: '新規').sole.fetch('id') + tree = [] + 5.times { tree = [{ 'tag_id' => tag.id, 'children' => tree }] } + expect_group_snapshot(id, number: 1, members:, placements: tree) + end + + context 'updating a migrated group' do + let!(:page) { legacy_group('更新', '- 洗澡鹿') } + let!(:old_revision) { page.current_revision } + + before { migrate_groups } + + def update_group body: '- 伊地知ニジカ', title: 'タググループ:更新', request_headers: headers + put "/wiki/#{ page.id }", params: { title:, body: }, headers: request_headers + end + + it 'replaces current membership and placement, retaining the old snapshot' do + old = group_version(page.id, 1) + update_group + expect(response).to have_http_status(:ok) + expect_group_snapshot( + page.id, number: 2, + members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }], + placements: [{ 'tag_id' => other_tag.id, 'children' => [] }]) + 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) + end + + it 'accepts the current base_revision_id used by the existing frontend' do + put "/wiki/#{ page.id }", + params: { title: page.title, 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) + end + + it 'records the editor and update time' do + editor = create(:user, :member, name: '編輯者') + travel_to(Time.current + 1.hour) do + update_group(request_headers: { 'X-Transfer-Code' => editor.inheritance_code }) + expect(response).to have_http_status(:ok) + current = group_rows('tag_groups', id: page.id).sole + version = group_version(page.id, 2) + expect(current).to include('updated_by_user_id' => editor.id) + expect(version).to include('created_by_user_id' => editor.id) + expect(current.fetch('updated_at')).to be_within(1.second).of(Time.current) + expect(version.fetch('created_at')).to be_within(1.second).of(Time.current) + end + end + + ['- 不明なタグ', '- タググループ:不明', '', '- :'].each do |body| + it "rejects invalid update #{ body.inspect } atomically" do + expect_rejected_without_writes { update_group(body:) } + end + end + + it 'rejects direct self inclusion' do + expect_rejected_without_writes { update_group(body: '- タググループ:更新') } + end + + it 'rejects indirect cyclic membership' do + seed_group( + name: '子', members: [{ 'tag_group_id' => page.id, 'version_no' => 1 }], + placements: [{ 'tag_group_id' => page.id, 'children' => [] }]) + expect_rejected_without_writes { update_group(body: '- タググループ:子') } + end + + it 'rejects duplicate rename without retaining a partial title or version' do + seed_group(name: '既存', description: '既存') + expect_rejected_without_writes { update_group(title: 'タググループ:既存') } + end + end + + [:create, :update].each do |operation| + context "#{ operation } access and atomicity" do + let!(:page) do + next unless operation == :update + + legacy = legacy_group('更新', '- 洗澡鹿') + seed_group(name: '更新', id: legacy.id, members:, placements:) + legacy + end + let!(:child_id) { seed_group(name: '子グループ', description: '子') } + + def write_group operation, request_headers + body = "- 伊地知ニジカ\n- タググループ:子グループ" + if operation == :create + create_group(body:, request_headers:) + else + put "/wiki/#{ page.id }", + params: { title: 'タググループ:更新', body: }, headers: request_headers + end + end + + [nil, :guest, :banned].each do |role| + it "rejects #{ role || 'anonymous' } without writes" do + actor = case role + when :guest then create(:user, :guest) + when :banned then create(:user, :member, :banned) + end + request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { } + before_state = group_state + write_group(operation, request_headers) + expect(response).to have_http_status(role ? :forbidden : :unauthorized) + expect(group_state).to eq(before_state) + end + end + + it 'rejects a banned IP even for a member' do + IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current) + before_state = group_state + write_group(operation, headers) + expect(response).to have_http_status(:forbidden) + expect(group_state).to eq(before_state) + end + + (TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions']) + .each do |table| + it "rolls back all writes after a real #{ table } write fails" do + expect_atomic_group_write(table) { write_group(operation, headers) } + end + end + end + end +end diff --git a/backend/spec/requests/tag_groups_spec.rb b/backend/spec/requests/tag_groups_spec.rb new file mode 100644 index 0000000..e9c2572 --- /dev/null +++ b/backend/spec/requests/tag_groups_spec.rb @@ -0,0 +1,308 @@ +require 'rails_helper' + +RSpec.describe 'Tag group native API', type: :request do + include TagGroupRecords + + let!(:user) { create_member_user! } + let!(:group_id) { seed_group(name: '専用API', description: '説明') } + + before { I18n.locale = :ja } + + def expect_native_group value + expect(value).to include( + 'id' => group_id, 'name' => '専用API', 'description' => '説明', + 'version_no' => 1, 'members' => [], 'placements' => [], + 'created_by_user' => UserRepr.base(user), 'updated_by_user' => UserRepr.base(user)) + current = group_rows('tag_groups', id: group_id).sole + expect(value).to include( + 'created_at' => current.fetch('created_at').iso8601(3), + 'updated_at' => current.fetch('updated_at').iso8601(3)) + end + + it 'serves the collection as JSON without reading Wiki tables' do + without_group_legacy_reads { get '/tag_groups' } + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq('application/json') + expect(json).to be_a(Array) + expect_native_group(json.find { _1.fetch('id') == group_id }) + end + + it 'serves an independent group as JSON without reading Wiki tables' do + without_group_legacy_reads { get "/tag_groups/#{ group_id }" } + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq('application/json') + expect_native_group(json) + end + + it 'provides the confirmed PUT update route' do + route = Rails.application.routes.recognize_path("/tag_groups/#{ group_id }", method: :put) + expect(route).to include(action: 'update', id: group_id.to_s) + end + + [:create, :update].each do |operation| + [nil, :guest, :banned].each do |role| + it "rejects #{ role || 'anonymous' } native #{ operation } without writes" do + actor = case role + when :guest then create(:user, :guest) + when :banned then create(:user, :member, :banned) + end + headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { } + before_state = group_state + if operation == :create + post '/tag_groups', params: { }, headers:, as: :json + else + put "/tag_groups/#{ group_id }", params: { }, headers:, as: :json + end + expect(response).to have_http_status(role ? :forbidden : :unauthorized) + expect(group_state).to eq(before_state) + end + end + + it "rejects a banned IP for native #{ operation }" do + IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current) + headers = { 'X-Transfer-Code' => user.inheritance_code } + before_state = group_state + if operation == :create + post '/tag_groups', params: { }, headers:, as: :json + else + put "/tag_groups/#{ group_id }", params: { }, headers:, as: :json + end + expect(response).to have_http_status(:forbidden) + expect(group_state).to eq(before_state) + end + end + + context 'structured membership and placements' do + let!(:tag) { group_tag('掲載タグ') } + let!(:unplaced) { group_tag('未掲載タグ') } + let!(:child_id) { seed_group(name: '子', description: '子の説明') } + let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let(:members) do + [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }, { 'tag_id' => unplaced.id }] + end + let(:snapshot_members) do + [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }, + { 'tag_group_id' => child_id, 'version_no' => 1 }, + { 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }] + end + let(:placements) do + [{ 'section_name' => '区分', 'description' => '区分の説明', 'children' => [ + { 'tag_id' => tag.id, 'children' => [ + { 'tag_group_id' => child_id, 'children' => [] }] }, + { 'tag_id' => tag.id, 'children' => [] }] }] + end + let(:payload) { { name: '構造化', description: '全体の説明', members:, placements: } } + + def native_write operation, attributes = payload + if operation == :create + post '/tag_groups', params: attributes, headers:, as: :json + else + put "/tag_groups/#{ group_id }", params: attributes, headers:, as: :json + end + end + + def expect_structured_response + tag_repr = { 'id' => tag.id, 'name' => '掲載タグ', 'category' => tag.category } + unplaced_repr = { 'id' => unplaced.id, 'name' => '未掲載タグ', + 'category' => unplaced.category } + expect(json.fetch('members')).to contain_exactly( + include('tag' => include(tag_repr)), + include('tag' => include(unplaced_repr)), + include('tag_group' => include('id' => child_id, 'name' => '子'))) + expect(json.fetch('placements')).to match([ + include('section_name' => '区分', 'description' => '区分の説明', 'children' => [ + include('tag' => include(tag_repr), 'children' => [ + include('tag_group' => include('id' => child_id, 'name' => '子'), + 'children' => [])]), + include('tag' => include(tag_repr), 'children' => [])])]) + end + + [:create, :update].each do |operation| + it "#{ operation }s structured data and keeps unplaced membership" do + previous = group_version(group_id, 1) + native_write(operation) + expect(response).to have_http_status(operation == :create ? :created : :ok) + id = operation == :create ? json.fetch('id') : group_id + number = operation == :create ? 1 : 2 + expect_group_snapshot( + id, number:, members: snapshot_members, placements:, description: '全体の説明') + expect(group_version(group_id, 1)).to eq(previous) + get "/tag_groups/#{ id }" + expect(response).to have_http_status(:ok) + expect(json).to include( + 'id' => id, 'name' => '構造化', 'description' => '全体の説明', 'version_no' => number, + 'created_by_user' => UserRepr.base(user), 'updated_by_user' => UserRepr.base(user)) + expect_structured_response + end + + { + 'blank name' => { name: '' }, + 'missing name' => { name: nil }, + 'oversized name' => { name: '長' * 256 }, + 'duplicate name' => { name: '子' }, + 'placement without a kind' => { placements: [{ children: [] }] }, + 'empty Section name' => { placements: [{ section_name: '', children: [] }] } } + .each do |label, invalid| + it "rejects #{ label } on #{ operation } without partial writes" do + before_state = group_state + native_write(operation, payload.merge(invalid)) + aggregate_failures do + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + end + end + + it "rejects multiple placement kinds on #{ operation }" do + before_state = group_state + invalid = [{ tag_id: tag.id, section_name: '不正', children: [] }] + native_write(operation, payload.merge(placements: invalid)) + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + + it "rejects a missing referenced Tag on #{ operation }" do + missing = Tag.maximum(:id).to_i + 1 + before_state = group_state + native_write(operation, payload.merge( + members: [{ tag_id: missing }], placements: [{ tag_id: missing, children: [] }])) + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + + it "rejects a missing referenced group on #{ operation }" do + missing = group_rows('tag_groups').map { _1.fetch('id') }.max + 1 + before_state = group_state + native_write(operation, payload.merge( + members: [{ tag_group_id: missing }], + placements: [{ tag_group_id: missing, children: [] }])) + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + + it "rejects six descendant placement levels on #{ operation }" do + nodes = [] + 6.times { nodes = [{ tag_id: tag.id, children: nodes }] } + before_state = group_state + native_write(operation, payload.merge(placements: nodes)) + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + end + + it 'preserves membership-only data in structured reads without inventing placements' do + id = seed_group(name: '未掲載のみ', members: [snapshot_members.last]) + without_group_legacy_reads { get "/tag_groups/#{ id }" } + expect(response).to have_http_status(:ok) + expect(json.fetch('placements')).to eq([]) + expect(json.fetch('members')).to match([ + include('tag' => include('id' => unplaced.id, 'name' => '未掲載タグ'))]) + end + + it 'rejects indirect containment cycles' do + parent = seed_group( + name: '親', members: [{ 'tag_group_id' => group_id, 'version_no' => 1 }]) + before_state = group_state + native_write(:update, payload.merge( + members: [{ tag_group_id: parent }], placements: [])) + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + + it 'rejects direct containment even without a corresponding placement' do + before_state = group_state + native_write(:update, payload.merge( + members: [{ tag_group_id: group_id }], placements: [])) + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + + it 'preserves list Section descriptions in native structured data' do + nodes = [{ tag_id: tag.id, children: [ + { section_name: 'リスト内区分', description: '消してはいけない説明', children: [] }] }] + native_write(:update, payload.merge(placements: nodes)) + expect(response).to have_http_status(:ok) + expect(group_version(group_id, 2).fetch('placements')).to eq( + JSON.parse(nodes.to_json)) + get "/tag_groups/#{ group_id }" + expect(response).to have_http_status(:ok) + section = json.fetch('placements').sole.fetch('children').sole + expect(section).to include( + 'section_name' => 'リスト内区分', 'description' => '消してはいけない説明') + end + + context 'compatibility writes for representable structures' do + let(:group_id) do + page = legacy_group('専用API', '説明') + seed_group(name: '専用API', id: page.id, description: '説明') + end + let(:members) { [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }] } + let(:snapshot_members) do + [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }, + { 'tag_group_id' => child_id, 'version_no' => 1 }] + end + + [:create, :update].each do |operation| + it "synchronises Wiki current state and histories on native #{ operation }" do + native_write(operation) + expect(response).to have_http_status(operation == :create ? :created : :ok) + id = operation == :create ? json.fetch('id') : group_id + 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) + get group_title_path('構造化') + expect(response).to have_http_status(:ok) + expect(json.fetch('body')).to eq(page.body) + end + + (TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions']) + .each do |table| + it "rolls back native #{ operation } after #{ table } writes" do + expect_atomic_group_write(table) { native_write(operation) } + end + end + end + + it 'uses AUTO_INCREMENT for a new group instead of supplying a compatibility ID' do + inserts = [] + subscriber = lambda do |*args| + sql = args.last.fetch(:sql) + inserts << sql if sql.match?(/\A\s*INSERT INTO `?tag_groups`?\s/i) + end + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do + native_write(:create) + end + expect(response).to have_http_status(:created) + expect(inserts.size).to eq(1) + columns = inserts.sole[/\((.*?)\)/, 1].split(',').map { _1.strip.delete('`') } + expect(columns).not_to include('id') + end + + it 'updates a native group through Wiki and then reads structured current data' do + native_write(:create) + expect(response).to have_http_status(:created) + id = json.fetch('id') + previous = group_version(id, 1) + put "/wiki/#{ id }", + params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers + expect(response).to have_http_status(:ok) + expect_group_snapshot( + id, number: 2, + members: [{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }], + placements: [{ 'tag_id' => unplaced.id, 'children' => [] }]) + expect(group_version(id, 1)).to eq(previous) + get "/tag_groups/#{ id }" + expect(response).to have_http_status(:ok) + expect(json).to include('version_no' => 2) + expect(json.fetch('members')).to match([ + include('tag' => include('id' => unplaced.id, 'name' => '未掲載タグ'))]) + end + end + end +end diff --git a/backend/spec/support/tag_group_records.rb b/backend/spec/support/tag_group_records.rb new file mode 100644 index 0000000..87f1aaf --- /dev/null +++ b/backend/spec/support/tag_group_records.rb @@ -0,0 +1,189 @@ +require 'cgi' + +# SQL fixtures use the migration contract without inventing application models. +# Include explicitly; these helpers must not affect unrelated examples. +module TagGroupRecords + GROUP_TABLES = [ + '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_descriptions'] + + def group_connection = ApplicationRecord.connection + + def group_rows table, conditions = { } + clauses = conditions.map do |column, value| + "#{ group_connection.quote_column_name(column) } = #{ group_connection.quote(value) }" + end + sql = "SELECT * FROM #{ group_connection.quote_table_name(table) }" + sql += " WHERE #{ clauses.join(' AND ') }" if clauses.any? + group_connection.select_all(sql).to_a.sort_by { |row| row.values.map(&:to_s) } + end + + def insert_group_row table, attributes + columns = attributes.keys.map { group_connection.quote_column_name(_1) }.join(', ') + values = attributes.values.map { group_connection.quote(_1) }.join(', ') + group_connection.insert( + "INSERT INTO #{ group_connection.quote_table_name(table) } (#{ columns }) " + + "VALUES (#{ values })") + end + + def group_state + WRITE_TABLES.to_h { |table| [table, group_rows(table)] } + end + + def expect_atomic_group_write table + before_state = group_state + failure = Class.new(StandardError) + injected = false + subscriber = lambda do |*args| + sql = args.last.fetch(:sql) + next unless sql.match?(/\A\s*(?:INSERT INTO|UPDATE)\s+`?#{ table }`?\s/i) + + injected = true + raise failure, 'injected after SQL write' + end + begin + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield } + rescue failure + # Inspect the database after the operation's transaction unwinds. + end + aggregate_failures do + expect(injected).to be(true), "No INSERT/UPDATE reached #{ table }" + expect(group_state).to eq(before_state) + end + end + + def group_version group_id, number + row = group_rows('tag_group_versions', tag_group_id: group_id, version_no: number).sole + row.merge('members' => JSON.parse(row.fetch('members')), + 'placements' => JSON.parse(row.fetch('placements'))) + end + + def group_tag name + tag = create(:tag, primary_name: name) + TagVersioning.ensure_snapshot!(tag, created_by_user: user) + 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 + end + + def seed_group name:, placements: [], members: [], description: nil, id: nil + now = Time.current.change(usec: 0) + attributes = { + name:, description:, version_no: 1, + created_by_user_id: user.id, updated_by_user_id: user.id, + created_at: now, updated_at: now } + attributes[:id] = id if id + group_id = insert_group_row('tag_groups', attributes) + insert_group_row('tag_group_versions', { + tag_group_id: group_id, version_no: 1, event_type: 'create', + name:, description:, members: members.to_json, placements: placements.to_json, + created_by_user_id: user.id, created_at: now }) + members.each do |member| + tag_member = member.key?('tag_id') + table = tag_member ? 'tag_group_tags' : 'tag_group_groups' + key = tag_member ? :tag_id : :child_tag_group_id + value = member.fetch(tag_member ? 'tag_id' : 'tag_group_id') + insert_group_row(table, { + tag_group_id: group_id, key => value, + created_by_user_id: user.id, created_at: now }) + end + position = 0 + append = lambda do |nodes, parent| + nodes.each do |node| + position += 1 + own_position = position + insert_group_row('tag_group_placements', { + tag_group_id: group_id, position:, parent_position: parent, + section_name: node['section_name'], description: node['description'], + tag_id: node['tag_id'], child_tag_group_id: node['tag_group_id'], + created_by_user_id: user.id, updated_by_user_id: user.id, + created_at: now, updated_at: now }) + append.call(node.fetch('children'), own_position) + end + end + append.call(placements, nil) + group_id + end + + def without_group_legacy_reads + queries = [] + subscriber = lambda do |*args| + sql = args.last.fetch(:sql) + if (sql.match?(/\bSELECT\b/i) && + sql.match?(/\bwiki_(pages|revisions|versions|lines|revision_lines)\b/i)) + queries << sql + end + end + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield } + expect(queries).to be_empty + end + + def group_title_path name + "/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) + expect(current).to include('version_no' => number, 'description' => description), + @group_migration_messages&.join("\n") + expect(version).to include( + 'name' => current.fetch('name'), 'description' => description, + 'version_no' => number, 'placements' => placements) + expect(version.fetch('members')).to match_array(members) + expect(group_rows('tag_group_tags', tag_group_id: group_id).map { _1.fetch('tag_id') }) + .to match_array(members.filter_map { _1['tag_id'] }) + children = group_rows('tag_group_groups', tag_group_id: group_id) + expect(children.map { _1.fetch('child_tag_group_id') }) + .to match_array(members.filter_map { _1['tag_group_id'] }) + + rows = group_rows('tag_group_placements', tag_group_id: group_id) + tree = lambda do |parent| + rows.select { _1['parent_position'] == parent }.sort_by { _1.fetch('position') } + .map do |row| + node = if row['tag_id'] + { 'tag_id' => row.fetch('tag_id') } + elsif row['child_tag_group_id'] + { 'tag_group_id' => row.fetch('child_tag_group_id') } + else + { 'section_name' => row.fetch('section_name'), 'description' => row['description'] } + end + node.merge('children' => tree.call(row.fetch('position'))) + end + end + expect(tree.call(nil)).to eq(placements) + end +end