このコミットが含まれているのは:
@@ -141,6 +141,19 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(2)
|
||||
end
|
||||
|
||||
it 'ignores a stale base_revision_id without rejecting the update' do
|
||||
update_group
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
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(3)
|
||||
expect(page.reload.body.strip).to eq('- 洗澡鹿')
|
||||
end
|
||||
|
||||
it 'records the editor and update time' do
|
||||
editor = create(:user, :member, name: '編輯者')
|
||||
travel_to(Time.current + 1.hour) do
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group write integrity', 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 native_payload name: '更新', nodes: placements, member_rows: members
|
||||
{ name:, description: nil, members: member_rows.map do |member|
|
||||
member.except('version_no')
|
||||
end, placements: nodes }
|
||||
end
|
||||
|
||||
def put_wiki id, title:, body: '- 伊地知ニジカ', base: nil
|
||||
put "/wiki/#{ id }", params: { title:, body:, base_revision_id: base }, headers: headers
|
||||
end
|
||||
|
||||
def create_help body
|
||||
Wiki::Commit.create_legacy_content!(
|
||||
tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user)
|
||||
end
|
||||
|
||||
def expect_no_writes
|
||||
before_state = group_state
|
||||
yield
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
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:)
|
||||
|
||||
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('- 伊地知ニジカ')
|
||||
end
|
||||
|
||||
it 'updates Help with a colliding group ID without altering the group' 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)
|
||||
|
||||
put_wiki(id, title: 'Help:改名', body: 'Help after')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(page.reload.body).to eq('Help after')
|
||||
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!(
|
||||
tag_name: tag.tag_name('ja'), body: 'description before', created_by_user: user)
|
||||
id = seed_group(name: '更新', id: page.id, members:, placements:)
|
||||
group_before = TagGroup.find(id).attributes
|
||||
|
||||
put_wiki(id, title: '洗澡鹿', body: 'description after')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(TagDescription.find(id).body).to eq('description after')
|
||||
expect(page.reload.body).to eq('description after')
|
||||
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')
|
||||
|
||||
put_wiki(id, title: 'Help:既存', body: 'Help after')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(page.reload.body).to eq('Help after')
|
||||
expect(description.reload.body).to eq('description')
|
||||
expect(TagGroup.find(id).version_no).to eq(1)
|
||||
expect_no_writes { put_wiki(id, title: '洗澡鹿', body: '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 after')
|
||||
expect(description.reload.body).to eq('description')
|
||||
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
|
||||
|
||||
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:改名' })
|
||||
expect(page.id).not_to eq(help.id)
|
||||
expect(page.body).to eq('- 洗澡鹿')
|
||||
expect(page.current_revision.body).to eq(page.body)
|
||||
expect(page.wiki_versions.sole.body).to eq(page.body)
|
||||
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
|
||||
end
|
||||
|
||||
context 'identifier Markdown' do
|
||||
['[洗澡鹿](https://example.com)', '*洗澡鹿*', '**洗澡鹿**', '`洗澡鹿`',
|
||||
'',
|
||||
'[タググループ:子](https://example.com)', '*区分*:',
|
||||
'[区分](https://example.com):'].each do |label|
|
||||
[:create, :update].each do |operation|
|
||||
it "rejects decorated #{ label } on #{ operation } without writes" do
|
||||
seed_group(name: '子', description: '子')
|
||||
id = seed_group(name: '更新', members:, placements:)
|
||||
expect_no_writes do
|
||||
if operation == :create
|
||||
post '/wiki', params: { title: 'タググループ:新規', body: "- #{ label }" },
|
||||
headers: headers
|
||||
else
|
||||
put_wiki(id, title: 'タググループ:更新', body: "- #{ label }")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
['# [区分](https://example.com)', '# *区分*', '# `区分`', '# 区分 {#custom}',
|
||||
'# '].each do |heading|
|
||||
it "rejects a decorated heading #{ heading } without writes" do
|
||||
expect_no_writes do
|
||||
post '/wiki', params: { title: 'タググループ:新規', body: "#{ heading }\n- 洗澡鹿" },
|
||||
headers: headers
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'preserves allowed markup in group and heading descriptions' do
|
||||
description = '[参照](https://example.com "リンクの説明") と *強調* と ``a`b``'
|
||||
body = "#{ description }\n\n# 区分\n#{ description }\n\n- 洗澡鹿"
|
||||
post '/wiki', params: { title: 'タググループ:説明構文', body: }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
id = json.fetch('id')
|
||||
version = group_version(id, 1)
|
||||
expect(version.fetch('description')).to eq(description)
|
||||
expect(version.fetch('placements').sole.fetch('description')).to eq(description)
|
||||
end
|
||||
end
|
||||
|
||||
context 'structure audit information' do
|
||||
let!(:child_id) { seed_group(name: '子', description: '子') }
|
||||
let(:members) do
|
||||
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
|
||||
{ 'tag_group_id' => child_id, 'version_no' => 1 }]
|
||||
end
|
||||
let(:placements) do
|
||||
[{ 'tag_id' => tag.id, 'children' => [
|
||||
{ 'tag_group_id' => child_id, 'children' => [] }] }]
|
||||
end
|
||||
let!(:id) { seed_group(name: '更新', members:, placements:) }
|
||||
let!(:editor) { create(:user, :member) }
|
||||
let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } }
|
||||
|
||||
it 'keeps all membership and placement audit fields on a name-only edit' do
|
||||
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
|
||||
travel_to(1.hour.from_now) do
|
||||
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(group_state.slice(*before_rows.keys)).to eq(before_rows)
|
||||
expect(group_version(id, 2)).to include('name' => '改名', 'members' => members)
|
||||
end
|
||||
|
||||
it 'keeps membership and placement audit fields on a description-only edit' do
|
||||
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
|
||||
travel_to(1.hour.from_now) do
|
||||
put "/tag_groups/#{ id }",
|
||||
params: native_payload.merge(description: '説明を追加'), headers:, as: :json
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(group_state.slice(*before_rows.keys)).to eq(before_rows)
|
||||
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
|
||||
new_members = [{ 'tag_id' => tag.id }]
|
||||
new_nodes = [{ 'tag_id' => tag.id, 'children' => [] }]
|
||||
expect_atomic_group_write('wiki_versions') do
|
||||
put "/tag_groups/#{ id }",
|
||||
params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json
|
||||
end
|
||||
end
|
||||
|
||||
it 'changes only added or removed membership and keeps placement creation audit' do
|
||||
tag_row = TagGroupTag.find([id, tag.id]).attributes
|
||||
placement_row = TagGroupPlacement.find([id, 1]).attributes
|
||||
new_members = [{ 'tag_id' => tag.id }, { 'tag_id' => other_tag.id }]
|
||||
new_nodes = [{ 'tag_id' => tag.id, 'children' => [
|
||||
{ 'tag_id' => other_tag.id, 'children' => [] }] }]
|
||||
child_row = TagGroupPlacement.find([id, 2]).attributes
|
||||
travel_to(1.hour.from_now) do
|
||||
put "/tag_groups/#{ id }",
|
||||
params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(TagGroupTag.find([id, tag.id]).attributes).to eq(tag_row)
|
||||
expect(TagGroupGroup.exists?(tag_group_id: id, child_tag_group_id: child_id)).to be(false)
|
||||
expect(TagGroupTag.find([id, other_tag.id]).created_by_user_id).to eq(editor.id)
|
||||
expect(TagGroupPlacement.find([id, 1]).attributes).to eq(placement_row)
|
||||
changed = TagGroupPlacement.find([id, 2])
|
||||
expect(changed).to have_attributes(
|
||||
tag_id: other_tag.id, child_tag_group_id: nil, updated_by_user_id: editor.id,
|
||||
created_at: child_row.fetch('created_at'),
|
||||
created_by_user_id: child_row.fetch('created_by_user_id'))
|
||||
expect(changed.updated_at).to be > child_row.fetch('updated_at')
|
||||
expect(group_version(id, 1).fetch('members')).to eq(members)
|
||||
end
|
||||
end
|
||||
end
|
||||
新しいイシューから参照
ユーザーをブロックする