このコミットが含まれているのは:
2026-10-10 11:14:01 +09:00
コミット 7840e15d1a
8個のファイルの変更、986行の追加、12行の削除
+13
ファイルの表示
@@ -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
+329
ファイルの表示
@@ -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/image.png)',
'[タググループ:子](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}',
'# ![区分](https://example.com/image.png)'].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