diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 113b3bb..d993dc3 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -453,7 +453,6 @@ class WikiPagesController < ApplicationController WikiPage .joins(:tag_name) - .where.not(id: descriptions.select(:id)) .where.not('tag_names.name LIKE ?', "#{ TagGroupRepr::PREFIX }%") .where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)', descriptions.select(:tag_id)) diff --git a/backend/spec/requests/tag_group_wiki_spec.rb b/backend/spec/requests/tag_group_wiki_spec.rb index f410450..2104beb 100644 --- a/backend/spec/requests/tag_group_wiki_spec.rb +++ b/backend/spec/requests/tag_group_wiki_spec.rb @@ -212,6 +212,79 @@ 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 + Wiki::Commit.create_legacy_content!( + 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 all three resources without deduplicating their numeric ID' 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_help.id, colliding_help.title], + [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_help.current_revision.id, colliding_help.title], + [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 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) diff --git a/backend/spec/requests/tag_group_wiki_write_spec.rb b/backend/spec/requests/tag_group_wiki_write_spec.rb index 9b51092..4c80a63 100644 --- a/backend/spec/requests/tag_group_wiki_write_spec.rb +++ b/backend/spec/requests/tag_group_wiki_write_spec.rb @@ -115,6 +115,65 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do put "/wiki/#{ page.id }", params: { title:, body: }, headers: request_headers end + [1, 3].each do |repetitions| + it "keeps identical Markdown unchanged after #{ repetitions } Wiki PUTs" do + body = page.body + travel_to(1.hour.from_now) do + expect_noop_group_update(group_id: page.id, page:) do + repetitions.times do + update_group(body:) + expect(response).to have_http_status(:ok) + end + end + end + end + end + + 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) + + travel_to(1.hour.from_now) do + expect_noop_group_update(group_id: page.id, page:) do + put "/wiki/#{ page.id }", + params: { title: page.title, body:, base_revision_id: old_revision.id }, + headers: headers + end + end + end + + it 'keeps the original editor on identical Markdown from another member' do + editor = create(:user, :member) + body = page.body + travel_to(1.hour.from_now) do + expect_noop_group_update(group_id: page.id, page:) do + update_group(body:, request_headers: { 'X-Transfer-Code' => editor.inheritance_code }) + end + end + end + + it 'does not add history for Wiki content already changed through Native' do + changed_members = [{ 'tag_id' => other_tag.id }] + changed_placements = [{ 'tag_id' => other_tag.id, 'children' => [] }] + put "/tag_groups/#{ page.id }", + params: { name: '更新', description: nil, + members: changed_members, placements: changed_placements }, + headers:, as: :json + 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: changed_placements) + body = page.reload.body + expect(body).to eq('- 伊地知ニジカ') + + travel_to(1.hour.from_now) do + expect_noop_group_update(group_id: page.id, page:) { update_group(body:) } + end + end + it 'replaces current membership and placement, retaining the old snapshot' do old = group_version(page.id, 1) update_group diff --git a/backend/spec/requests/tag_groups_spec.rb b/backend/spec/requests/tag_groups_spec.rb index e9c2572..33ba293 100644 --- a/backend/spec/requests/tag_groups_spec.rb +++ b/backend/spec/requests/tag_groups_spec.rb @@ -2,6 +2,7 @@ require 'rails_helper' RSpec.describe 'Tag group native API', type: :request do include TagGroupRecords + include ActiveSupport::Testing::TimeHelpers let!(:user) { create_member_user! } let!(:group_id) { seed_group(name: '専用API', description: '説明') } @@ -242,6 +243,154 @@ RSpec.describe 'Tag group native API', type: :request do { 'tag_group_id' => child_id, 'version_no' => 1 }] end + it 'treats identical native content with unplaced membership as a no-op' do + unplaced_payload = payload.merge( + name: '専用API', members: members + [{ 'tag_id' => unplaced.id }]) + native_write(:update, unplaced_payload) + expect(response).to have_http_status(:ok) + expect_group_snapshot( + 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 + native_write(:update, unplaced_payload) + end + end + end + + context 'no-op updates against synchronised state' do + let(:synchronised_payload) do + { name: '構造化', description: '全体の説明', + 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('- 掲載タグ', '- タググループ:子') + 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 + repetitions.times do + native_write(:update, synchronised_payload) + expect(response).to have_http_status(:ok) + end + end + end + end + end + + 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 + native_write(:update, synchronised_payload.merge( + members: synchronised_payload.fetch(:members).reverse)) + end + end + end + + 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 + put "/tag_groups/#{ group_id }", params: synchronised_payload, + headers: { 'X-Transfer-Code' => editor.inheritance_code }, as: :json + end + end + end + + it 'does not add history for Native content already changed through Wiki' do + put "/wiki/#{ group_id }", + params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers + expect(response).to have_http_status(:ok) + expect(compatibility_page.reload.body).to eq('- 未掲載タグ') + same_content = { + name: '構造化', description: nil, members: [{ 'tag_id' => unplaced.id }], + placements: [{ 'tag_id' => unplaced.id, 'children' => [] }] } + expect_group_snapshot( + group_id, number: 3, description: nil, + members: [{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }], + placements: same_content.fetch(:placements)) + + travel_to(1.hour.from_now) do + expect_noop_group_update(group_id:, page: compatibility_page) do + native_write(:update, same_content) + end + end + end + + ['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 + changed = synchronised_payload.deep_dup + section = changed.fetch(:placements).first + children = section.fetch('children') + case change + when 'name' then changed[:name] = '実際の改名' + when 'description' then changed[:description] = '実際の説明変更' + when 'membership addition' + expect(changed.fetch(:members)).not_to include('tag_id' => additional_tag.id) + changed[:members] << { 'tag_id' => additional_tag.id } + children << { 'tag_id' => additional_tag.id, 'children' => [] } + when 'membership removal' + changed[:members].reject! { _1.key?('tag_group_id') } + children.first['children'] = [] + when 'Section name' then section['section_name'] = '別区分' + when 'Section description' then section['description'] = '別の区分説明' + when 'placement order' then children.reverse! + when 'placement hierarchy' + children << children.first.fetch('children').pop + end + + 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) + expected_members = changed.fetch(:members).map do |member| + target = if member.key?('tag_id') + Tag.find(member.fetch('tag_id')) + else + TagGroup.find(member.fetch('tag_group_id')) + end + member.merge('version_no' => target.version_no) + end + expect_group_snapshot( + group_id, number: number + 1, members: expected_members, + placements: changed.fetch(:placements), description: changed.fetch(:description)) + expect(group.reload.name).to eq(changed.fetch(:name)) + 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) + end + end + end + [:create, :update].each do |operation| it "synchronises Wiki current state and histories on native #{ operation }" do native_write(operation) diff --git a/backend/spec/support/tag_group_records.rb b/backend/spec/support/tag_group_records.rb index 87f1aaf..ed3a929 100644 --- a/backend/spec/support/tag_group_records.rb +++ b/backend/spec/support/tag_group_records.rb @@ -34,6 +34,37 @@ module TagGroupRecords WRITE_TABLES.to_h { |table| [table, group_rows(table)] } end + def expect_noop_group_update group_id:, page: + 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| + sql = args.last.fetch(:sql) + if sql.match?(/\A\s*(?:INSERT INTO|UPDATE|DELETE FROM)\s+`?(?:#{ tables })`?\s/i) + writes << sql + end + end + + aggregate_failures 'no-op current state, history and SQL writes' do + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield } + expect(response).to have_http_status(:ok) + after_state = group_state + before_state.each do |table, rows| + expect(after_state.fetch(table)).to eq(rows), "No-op changed #{ table }" + 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 + def expect_atomic_group_write table before_state = group_state failure = Class.new(StandardError)