このコミットが含まれているのは:
@@ -453,7 +453,6 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
WikiPage
|
WikiPage
|
||||||
.joins(:tag_name)
|
.joins(:tag_name)
|
||||||
.where.not(id: descriptions.select(:id))
|
|
||||||
.where.not('tag_names.name LIKE ?', "#{ TagGroupRepr::PREFIX }%")
|
.where.not('tag_names.name LIKE ?', "#{ TagGroupRepr::PREFIX }%")
|
||||||
.where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)',
|
.where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)',
|
||||||
descriptions.select(:tag_id))
|
descriptions.select(:tag_id))
|
||||||
|
|||||||
@@ -212,6 +212,79 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
expect(json.fetch('body').strip).to eq('独立した説明')
|
expect(json.fetch('body').strip).to eq('独立した説明')
|
||||||
end
|
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
|
it 'resolves colliding group, Help and description IDs by title' do
|
||||||
help = Wiki::Commit.create_legacy_content!(
|
help = Wiki::Commit.create_legacy_content!(
|
||||||
tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user)
|
tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user)
|
||||||
|
|||||||
@@ -115,6 +115,65 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
|||||||
put "/wiki/#{ page.id }", params: { title:, body: }, headers: request_headers
|
put "/wiki/#{ page.id }", params: { title:, body: }, headers: request_headers
|
||||||
end
|
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
|
it 'replaces current membership and placement, retaining the old snapshot' do
|
||||||
old = group_version(page.id, 1)
|
old = group_version(page.id, 1)
|
||||||
update_group
|
update_group
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ require 'rails_helper'
|
|||||||
|
|
||||||
RSpec.describe 'Tag group native API', type: :request do
|
RSpec.describe 'Tag group native API', type: :request do
|
||||||
include TagGroupRecords
|
include TagGroupRecords
|
||||||
|
include ActiveSupport::Testing::TimeHelpers
|
||||||
|
|
||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
let!(:group_id) { seed_group(name: '専用API', description: '説明') }
|
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 }]
|
{ 'tag_group_id' => child_id, 'version_no' => 1 }]
|
||||||
end
|
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|
|
[:create, :update].each do |operation|
|
||||||
it "synchronises Wiki current state and histories on native #{ operation }" do
|
it "synchronises Wiki current state and histories on native #{ operation }" do
|
||||||
native_write(operation)
|
native_write(operation)
|
||||||
|
|||||||
@@ -34,6 +34,37 @@ module TagGroupRecords
|
|||||||
WRITE_TABLES.to_h { |table| [table, group_rows(table)] }
|
WRITE_TABLES.to_h { |table| [table, group_rows(table)] }
|
||||||
end
|
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
|
def expect_atomic_group_write table
|
||||||
before_state = group_state
|
before_state = group_state
|
||||||
failure = Class.new(StandardError)
|
failure = Class.new(StandardError)
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする