このコミットが含まれているのは:
2026-10-09 20:38:14 +09:00
コミット 890ffb57e1
7個のファイルの変更、1230行の追加、0行の削除
+110
ファイルの表示
@@ -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
+119
ファイルの表示
@@ -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
+117
ファイルの表示
@@ -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
+155
ファイルの表示
@@ -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
+232
ファイルの表示
@@ -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
+308
ファイルの表示
@@ -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
+189
ファイルの表示
@@ -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